Skip to content
Open
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,8 @@ 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)
- 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), in PR [#5797](https://github.com/microsoft/BotFramework-WebChat/pull/5797), by [@OEvgeny](https://github.com/OEvgeny)
- Added frozen window optimization to limit reference comparisons to the last 1,000 activities with deferred verification of the frozen portion, in PR [#5797](https://github.com/microsoft/BotFramework-WebChat/pull/5797), by [@OEvgeny](https://github.com/OEvgeny)

### Deprecated

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { getActivityLivestreamingMetadata, type WebChatActivity } from 'botframework-webchat-core';
import React, { useCallback, useMemo, useRef, type ReactNode } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';

import reduceIterable from '../../hooks/private/reduceIterable';
import useActivities from '../../hooks/useActivities';
import usePonyfill from '../Ponyfill/usePonyfill';
import type { ActivityKeyerContextType } from './private/Context';
import ActivityKeyerContext from './private/Context';
import getActivityId from './private/getActivityId';
Expand All @@ -17,6 +18,12 @@ type ActivityToKeyMap = Map<WebChatActivity, string>;
type ClientActivityIdToKeyMap = Map<string, string>;
type KeyToActivitiesMap = Map<string, readonly WebChatActivity[]>;

/** After this many ms of no activity changes, verify that the frozen portion was not modified. */
const FROZEN_CHECK_TIMEOUT = 10_000;

/** Only the last N activities are compared reference-by-reference on each render. */
const MUTABLE_ACTIVITY_WINDOW = 1_000;

/**
* React context composer component to assign a perma-key to every activity.
* This will support both `useGetActivityByKey` and `useGetKeyByActivity` custom hooks.
Expand All @@ -32,6 +39,7 @@ type KeyToActivitiesMap = Map<string, readonly WebChatActivity[]>;
* Local key are only persisted in memory. On refresh, they will be a new random key.
*/
const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | undefined }>) => {
const [{ cancelIdleCallback, clearTimeout, requestIdleCallback, setTimeout }] = usePonyfill();
const existingContext = useActivityKeyerContext(false);

if (existingContext) {
Expand All @@ -52,14 +60,25 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
const prevActivityKeysStateRef = useRef<readonly [readonly string[]]>(
Object.freeze([Object.freeze([])]) as readonly [readonly string[]]
);
const pendingFrozenCheckRef = useRef<
| {
readonly current: readonly WebChatActivity[];
readonly frozenBoundary: number;
readonly prev: readonly WebChatActivity[];
}
| undefined
>();
const warnedPositionsRef = useRef<Set<number>>(new Set());

// 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;
// Only the last MUTABLE_ACTIVITY_WINDOW activities are compared each render.
// Activities before the frozen boundary are assumed unchanged — O(1) instead of O(n).
const frozenBoundary = Math.max(0, Math.min(prevActivities.length, activities.length) - MUTABLE_ACTIVITY_WINDOW);
let commonPrefixLength = frozenBoundary;
const maxPrefix = Math.min(prevActivities.length, activities.length);

// eslint-disable-next-line security/detect-object-injection
Expand All @@ -78,6 +97,12 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
return prevActivityKeysStateRef.current;
}

// Schedule deferred verification of the frozen portion only now that we know:
// (1) the update is append-only and (2) there are actual content changes.
if (frozenBoundary) {
pendingFrozenCheckRef.current = Object.freeze({ current: activities, frozenBoundary, prev: prevActivities });
}

const { current: activityIdToKeyMap } = activityIdToKeyMapRef;
const { current: activityToKeyMap } = activityToKeyMapRef;
const { current: clientActivityIdToKeyMap } = clientActivityIdToKeyMapRef;
Expand Down Expand Up @@ -118,19 +143,15 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u

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;
if (!newKeys.length) {
// New activities might be 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.
return Object.freeze([prevActivityKeysStateRef.current[0]]) as readonly [readonly string[]];
}

// 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[]];
const nextKeys = Object.freeze([...prevActivityKeysStateRef.current[0], ...newKeys]);
const result = Object.freeze([nextKeys]) as readonly [readonly string[]];

prevActivityKeysStateRef.current = result;

Expand Down Expand Up @@ -180,6 +201,10 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
keyToActivitiesMapRef.current = nextKeyToActivitiesMap;
prevActivitiesRef.current = activities;

// Slow path did a full recalculation — no frozen check needed, reset warnings.
pendingFrozenCheckRef.current = undefined;
warnedPositionsRef.current.clear();

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

Expand All @@ -192,10 +217,53 @@ const ActivityKeyerComposer = ({ children }: Readonly<{ children?: ReactNode | u
activityToKeyMapRef,
clientActivityIdToKeyMapRef,
keyToActivitiesMapRef,
pendingFrozenCheckRef,
prevActivitiesRef,
prevActivityKeysStateRef
prevActivityKeysStateRef,
warnedPositionsRef
]);

// Deferred verification: after FROZEN_CHECK_TIMEOUT of quiet, validate that activities
// inside the frozen portion have not actually changed. Warn once per position if they did.
// Uses requestIdleCallback inside the timeout to avoid contending with the first post-stream repaint.
useEffect(() => {
const pending = pendingFrozenCheckRef.current;

if (!pending) {
return;
}

let idleHandle: ReturnType<NonNullable<typeof requestIdleCallback>> | undefined;

const runCheck = () => {
const { current: currentActivities, frozenBoundary, prev: prevFrozenActivities } = pending;

for (let i = 0; i < frozenBoundary; i++) {
// eslint-disable-next-line security/detect-object-injection
if (prevFrozenActivities[i] !== currentActivities[i] && !warnedPositionsRef.current.has(i)) {
warnedPositionsRef.current.add(i);

console.warn(
`botframework-webchat internal: change in activity at position ${i} was not applied because it is outside the mutable window of ${MUTABLE_ACTIVITY_WINDOW}.`
);
}
}
};

const timer = setTimeout(() => {
if (requestIdleCallback) {
idleHandle = requestIdleCallback(runCheck);
} else {
runCheck();
}
}, FROZEN_CHECK_TIMEOUT);

return () => {
clearTimeout(timer);
idleHandle !== undefined && cancelIdleCallback?.(idleHandle);
};
}, [activities, cancelIdleCallback, clearTimeout, requestIdleCallback, setTimeout]);

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