Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ Breaking changes in this release:
- Improved adaptive cards rendering in copilot variant, in PR [#5682](https://github.com/microsoft/BotFramework-WebChat/pull/5682), by [@OEvgeny](https://github.com/OEvgeny)
- Bumped to [`botframework-directlinejs@0.15.8`](https://www.npmjs.com/package/botframework-directlinejs/v/0.15.8) to include support for the new `streaming` property, by [@pranavjoshi001](https://github.com/pranavjoshi001), in PR [#5686](https://github.com/microsoft/BotFramework-WebChat/pull/5686)
- Removed unused deps `simple-git`, by [@compulim](https://github.com/compulim), in PR [#5786](https://github.com/microsoft/BotFramework-WebChat/pull/5786)
- Improved `ActivityKeyerComposer` performance for append scenarios by adding an incremental fast path that only processes newly-appended activities, in PR [#5790](https://github.com/microsoft/BotFramework-WebChat/pull/5790), by [@OEvgeny](https://github.com/OEvgeny)

### Deprecated

Expand Down
132 changes: 117 additions & 15 deletions packages/api/src/providers/ActivityKeyer/ActivityKeyerComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,102 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
}

const [activities] = useActivities();
const activityIdToKeyMapRef = useRef<Readonly<ActivityIdToKeyMap>>(Object.freeze(new Map()));
const activityToKeyMapRef = useRef<Readonly<ActivityToKeyMap>>(Object.freeze(new Map()));
const clientActivityIdToKeyMapRef = useRef<Readonly<ClientActivityIdToKeyMap>>(Object.freeze(new Map()));
const keyToActivitiesMapRef = useRef<Readonly<KeyToActivitiesMap>>(Object.freeze(new Map()));

// TODO: [P1] `useMemoWithPrevious` to check and cache the resulting array if it hasn't changed.
// Maps are intentionally mutable so the incremental fast path can append to them in-place.
Comment thread
OEvgeny marked this conversation as resolved.
const activityIdToKeyMapRef = useRef<ActivityIdToKeyMap>(new Map());
const activityToKeyMapRef = useRef<ActivityToKeyMap>(new Map());
const clientActivityIdToKeyMapRef = useRef<ClientActivityIdToKeyMap>(new Map());
const keyToActivitiesMapRef = useRef<KeyToActivitiesMap>(new Map());
const prevActivitiesRef = useRef<readonly WebChatActivity[]>(Object.freeze([]));
const prevActivityKeysStateRef = useRef<readonly [readonly string[]]>(
Object.freeze([Object.freeze([])]) as readonly [readonly string[]]
);

// Incremental keying: the fast path only processes newly-appended activities (O(delta) per render)
// instead of re-iterating all activities (O(n) per render, O(n²) total for n streaming pushes).
const activityKeysState = useMemo<readonly [readonly string[]]>(() => {
const prevActivities = prevActivitiesRef.current;

// Detect how many leading activities are identical (same reference) to the previous render.
let commonPrefixLength = 0;
const maxPrefix = Math.min(prevActivities.length, activities.length);

// eslint-disable-next-line security/detect-object-injection
while (commonPrefixLength < maxPrefix && prevActivities[commonPrefixLength] === activities[commonPrefixLength]) {
commonPrefixLength++;
}

const isAppendOnly = commonPrefixLength === prevActivities.length;

if (isAppendOnly) {
// Fast path: only new activities were appended — process them incrementally.
if (commonPrefixLength === activities.length) {
// Array reference changed but content is identical.
prevActivitiesRef.current = activities;

return prevActivityKeysStateRef.current;
}

const { current: activityIdToKeyMap } = activityIdToKeyMapRef;
const { current: activityToKeyMap } = activityToKeyMapRef;
const { current: clientActivityIdToKeyMap } = clientActivityIdToKeyMapRef;
const { current: keyToActivitiesMap } = keyToActivitiesMapRef;

const newKeys: string[] = [];

for (let i = commonPrefixLength; i < activities.length; i++) {
// eslint-disable-next-line security/detect-object-injection
const activity = activities[i];
const activityId = getActivityId(activity);
const clientActivityId = getClientActivityId(activity);
const typingActivityId = getActivityLivestreamingMetadata(activity)?.sessionId;

// Since we mutate maps in-place, a single lookup covers both "previous" and
// "current-iteration" entries — equivalent to the slow path's dual-map check.
const key =
(clientActivityId && clientActivityIdToKeyMap.get(clientActivityId)) ||
(typingActivityId && activityIdToKeyMap.get(typingActivityId)) ||
(activityId && activityIdToKeyMap.get(activityId)) ||
activityToKeyMap.get(activity) ||
uniqueId();

activityId && activityIdToKeyMap.set(activityId, key);
typingActivityId && activityIdToKeyMap.set(typingActivityId, key);
clientActivityId && clientActivityIdToKeyMap.set(clientActivityId, key);
activityToKeyMap.set(activity, key);

const activitiesForKey = keyToActivitiesMap.get(key);

keyToActivitiesMap.set(
key,
activitiesForKey ? Object.freeze([...activitiesForKey, activity]) : Object.freeze([activity])
);

!activitiesForKey && newKeys.push(key);
Comment thread
OEvgeny marked this conversation as resolved.
}

prevActivitiesRef.current = activities;

if (newKeys.length) {
const nextKeys = Object.freeze([...prevActivityKeysStateRef.current[0], ...newKeys]);
const result = Object.freeze([nextKeys]) as readonly [readonly string[]];

prevActivityKeysStateRef.current = result;

return result;
}

// New activities were added to existing keys — no new keys, but the keyToActivitiesMap
// was mutated. Return a new tuple reference so context consumers re-render and see the
// updated activities-per-key via getActivitiesByKey.
const result = Object.freeze([prevActivityKeysStateRef.current[0]]) as readonly [readonly string[]];

prevActivityKeysStateRef.current = result;

return result;
}

// Slow path: activities were removed or reordered — full recalculation.
const { current: activityIdToKeyMap } = activityIdToKeyMapRef;
const { current: activityToKeyMap } = activityToKeyMapRef;
const { current: clientActivityIdToKeyMap } = clientActivityIdToKeyMapRef;
Expand Down Expand Up @@ -76,20 +165,33 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
nextActivityToKeyMap.set(activity, key);
nextActivityKeys.add(key);

const activities = nextKeyToActivitiesMap.has(key) ? [...nextKeyToActivitiesMap.get(key)] : [];
const activitiesForKey = nextKeyToActivitiesMap.has(key) ? [...nextKeyToActivitiesMap.get(key)] : [];

activities.push(activity);
nextKeyToActivitiesMap.set(key, Object.freeze(activities));
activitiesForKey.push(activity);
nextKeyToActivitiesMap.set(key, Object.freeze(activitiesForKey));
});

activityIdToKeyMapRef.current = Object.freeze(nextActivityIdToKeyMap);
activityToKeyMapRef.current = Object.freeze(nextActivityToKeyMap);
clientActivityIdToKeyMapRef.current = Object.freeze(nextClientActivityIdToKeyMap);
keyToActivitiesMapRef.current = Object.freeze(nextKeyToActivitiesMap);
activityIdToKeyMapRef.current = nextActivityIdToKeyMap;
activityToKeyMapRef.current = nextActivityToKeyMap;
clientActivityIdToKeyMapRef.current = nextClientActivityIdToKeyMap;
keyToActivitiesMapRef.current = nextKeyToActivitiesMap;
prevActivitiesRef.current = activities;

const nextKeys = Object.freeze([...nextActivityKeys.values()]);
const result = Object.freeze([nextKeys]) as readonly [readonly string[]];

prevActivityKeysStateRef.current = result;

// `nextActivityKeys` could potentially same as `prevActivityKeys` despite reference differences, we should memoize it.
return Object.freeze([Object.freeze([...nextActivityKeys.values()])]) as readonly [readonly string[]];
}, [activities, activityIdToKeyMapRef, activityToKeyMapRef, clientActivityIdToKeyMapRef, keyToActivitiesMapRef]);
return result;
}, [
activities,
activityIdToKeyMapRef,
activityToKeyMapRef,
clientActivityIdToKeyMapRef,
keyToActivitiesMapRef,
prevActivitiesRef,
prevActivityKeysStateRef
]);

const getActivitiesByKey: (key?: string | undefined) => readonly WebChatActivity[] | undefined = useCallback(
(key?: string | undefined): readonly WebChatActivity[] | undefined => key && keyToActivitiesMapRef.current.get(key),
Expand Down
Loading