11import { 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
44import reduceIterable from '../../hooks/private/reduceIterable' ;
55import useActivities from '../../hooks/useActivities' ;
6+ import usePonyfill from '../Ponyfill/usePonyfill' ;
67import type { ActivityKeyerContextType } from './private/Context' ;
78import ActivityKeyerContext from './private/Context' ;
89import getActivityId from './private/getActivityId' ;
@@ -17,6 +18,12 @@ type ActivityToKeyMap = Map<WebChatActivity, string>;
1718type ClientActivityIdToKeyMap = Map < string , string > ;
1819type 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 */
3441const 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