Skip to content

Commit 69e5767

Browse files
committed
perf: reduce iterations in the fast path for the keyer composer updates
1 parent cd39b84 commit 69e5767

1 file changed

Lines changed: 82 additions & 15 deletions

File tree

packages/api/src/providers/ActivityKeyer/ActivityKeyerComposer.tsx

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { getActivityLivestreamingMetadata, type WebChatActivity } from 'botframework-webchat-core';
2-
import React, { useCallback, useMemo, useRef, type ReactNode } from 'react';
2+
import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
33

44
import reduceIterable from '../../hooks/private/reduceIterable';
55
import useActivities from '../../hooks/useActivities';
6+
import usePonyfill from '../Ponyfill/usePonyfill';
67
import type { ActivityKeyerContextType } from './private/Context';
78
import ActivityKeyerContext from './private/Context';
89
import getActivityId from './private/getActivityId';
@@ -17,6 +18,12 @@ type ActivityToKeyMap = Map<WebChatActivity, string>;
1718
type ClientActivityIdToKeyMap = Map<string, string>;
1819
type KeyToActivitiesMap = Map<string, readonly WebChatActivity[]>;
1920

21+
/** After this many ms of no activity changes, verify that the frozen portion was not modified. */
22+
const FROZEN_CHECK_TIMEOUT = 10_000;
23+
24+
/** Only the last N activities are compared reference-by-reference on each render. */
25+
const MUTABLE_ACTIVITY_WINDOW = 1_000;
26+
2027
/**
2128
* React context composer component to assign a perma-key to every activity.
2229
* This will support both `useGetActivityByKey` and `useGetKeyByActivity` custom hooks.
@@ -32,6 +39,7 @@ type KeyToActivitiesMap = Map<string, readonly WebChatActivity[]>;
3239
* Local key are only persisted in memory. On refresh, they will be a new random key.
3340
*/
3441
const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | undefined }>) => {
42+
const [{ cancelIdleCallback, clearTimeout, requestIdleCallback, setTimeout }] = usePonyfill();
3543
const existingContext = useActivityKeyerContext(false);
3644

3745
if (existingContext) {
@@ -52,21 +60,37 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
5260
const prevActivityKeysStateRef = useRef<readonly [readonly string[]]>(
5361
Object.freeze([Object.freeze([])]) as readonly [readonly string[]]
5462
);
63+
const pendingFrozenCheckRef = useRef<
64+
| {
65+
readonly current: readonly WebChatActivity[];
66+
readonly frozenBoundary: number;
67+
readonly prev: readonly WebChatActivity[];
68+
}
69+
| undefined
70+
>();
71+
const warnedPositionsRef = useRef<Set<number>>(new Set());
5572

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

61-
// Detect how many leading activities are identical (same reference) to the previous render.
62-
let commonPrefixLength = 0;
78+
// Only the last MUTABLE_ACTIVITY_WINDOW activities are compared each render.
79+
// Activities before the frozen boundary are assumed unchanged — O(1) instead of O(n).
80+
const frozenBoundary = Math.max(0, Math.min(prevActivities.length, activities.length) - MUTABLE_ACTIVITY_WINDOW);
81+
let commonPrefixLength = frozenBoundary;
6382
const maxPrefix = Math.min(prevActivities.length, activities.length);
6483

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

89+
// Schedule deferred verification of the frozen portion if any was skipped.
90+
pendingFrozenCheckRef.current = frozenBoundary
91+
? Object.freeze({ current: activities, frozenBoundary, prev: prevActivities })
92+
: undefined;
93+
7094
const isAppendOnly = commonPrefixLength === prevActivities.length;
7195

7296
if (isAppendOnly) {
@@ -118,19 +142,15 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
118142

119143
prevActivitiesRef.current = activities;
120144

121-
if (newKeys.length) {
122-
const nextKeys = Object.freeze([...prevActivityKeysStateRef.current[0], ...newKeys]);
123-
const result = Object.freeze([nextKeys]) as readonly [readonly string[]];
124-
125-
prevActivityKeysStateRef.current = result;
126-
127-
return result;
145+
if (!newKeys.length) {
146+
// New activities might be added to existing keys — no new keys, but the keyToActivitiesMap
147+
// was mutated. Return a new tuple reference so context consumers re-render and see the
148+
// updated activities-per-key via getActivitiesByKey.
149+
return Object.freeze([prevActivityKeysStateRef.current[0]]) as readonly [readonly string[]];
128150
}
129151

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

135155
prevActivityKeysStateRef.current = result;
136156

@@ -180,6 +200,10 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
180200
keyToActivitiesMapRef.current = nextKeyToActivitiesMap;
181201
prevActivitiesRef.current = activities;
182202

203+
// Slow path did a full recalculation — no frozen check needed, reset warnings.
204+
pendingFrozenCheckRef.current = undefined;
205+
warnedPositionsRef.current.clear();
206+
183207
const nextKeys = Object.freeze([...nextActivityKeys.values()]);
184208
const result = Object.freeze([nextKeys]) as readonly [readonly string[]];
185209

@@ -192,10 +216,53 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
192216
activityToKeyMapRef,
193217
clientActivityIdToKeyMapRef,
194218
keyToActivitiesMapRef,
219+
pendingFrozenCheckRef,
195220
prevActivitiesRef,
196-
prevActivityKeysStateRef
221+
prevActivityKeysStateRef,
222+
warnedPositionsRef
197223
]);
198224

225+
// Deferred verification: after FROZEN_CHECK_TIMEOUT of quiet, validate that activities
226+
// inside the frozen portion have not actually changed. Warn once per position if they did.
227+
// Uses requestIdleCallback inside the timeout to avoid contending with the first post-stream repaint.
228+
useEffect(() => {
229+
const pending = pendingFrozenCheckRef.current;
230+
231+
if (!pending) {
232+
return;
233+
}
234+
235+
let idleHandle: ReturnType<NonNullable<typeof requestIdleCallback>> | undefined;
236+
237+
const runCheck = () => {
238+
const { current: currentActivities, frozenBoundary, prev: prevFrozenActivities } = pending;
239+
240+
for (let i = 0; i < frozenBoundary; i++) {
241+
// eslint-disable-next-line security/detect-object-injection
242+
if (prevFrozenActivities[i] !== currentActivities[i] && !warnedPositionsRef.current.has(i)) {
243+
warnedPositionsRef.current.add(i);
244+
245+
console.warn(
246+
`botframework-webchat internal: change in activity at position ${i} was not applied because it is outside the mutable window of ${MUTABLE_ACTIVITY_WINDOW}.`
247+
);
248+
}
249+
}
250+
};
251+
252+
const timer = setTimeout(() => {
253+
if (requestIdleCallback) {
254+
idleHandle = requestIdleCallback(runCheck);
255+
} else {
256+
runCheck();
257+
}
258+
}, FROZEN_CHECK_TIMEOUT);
259+
260+
return () => {
261+
clearTimeout(timer);
262+
idleHandle !== undefined && cancelIdleCallback?.(idleHandle);
263+
};
264+
}, [activities, cancelIdleCallback, clearTimeout, requestIdleCallback, setTimeout]);
265+
199266
const getActivitiesByKey: (key?: string | undefined) => readonly WebChatActivity[] | undefined = useCallback(
200267
(key?: string | undefined): readonly WebChatActivity[] | undefined => key && keyToActivitiesMapRef.current.get(key),
201268
[keyToActivitiesMapRef]

0 commit comments

Comments
 (0)