@@ -6,6 +6,7 @@ import computeSortedActivities from './private/computeSortedActivities';
66import getLogicalTimestamp from './private/getLogicalTimestamp' ;
77import getPartGroupingMetadataMap from './private/getPartGroupingMetadataMap' ;
88import insertSorted from './private/insertSorted' ;
9+ import { toSpliced } from '@msinternal/botframework-webchat-base/utils' ;
910import { getLocalIdFromActivity } from './property/LocalId' ;
1011import { queryPositionFromActivity , setPositionInActivity } from './property/Position' ;
1112import {
@@ -41,6 +42,8 @@ import {
4142// - Always copy timestamp, except when it's a livestream of 2...N-1 revision
4243// - Part grouping timestamp is copied from upserting entry (either livestream session or activity)
4344
45+ const POSITION_INCREMENT = 1_000 ;
46+
4447const INITIAL_STATE = Object . freeze ( {
4548 activityIdToLocalIdMap : Object . freeze ( new Map ( ) ) ,
4649 activityMap : Object . freeze ( new Map ( ) ) ,
@@ -58,16 +61,170 @@ const INITIAL_STATE = Object.freeze({
5861// - Duplicate timestamps: activities without timestamp can't be sort deterministically with quick sort
5962
6063function upsert ( ponyfill : Pick < GlobalScopePonyfill , 'Date' > , state : State , activity : Activity ) : State {
64+ const activityLocalId = getLocalIdFromActivity ( activity ) ;
65+ const logicalTimestamp = getLogicalTimestamp ( activity , ponyfill ) ;
66+ const activityLivestreamingMetadata = getActivityLivestreamingMetadata ( activity ) ;
67+
68+ // #region Streaming fast path
69+ //
70+ // For revision 2..N-1 of an existing, non-finalized livestream session without HowTo grouping:
71+ // avoid the heavier full rebuild path, including sortedChatHistoryList recomputation,
72+ // computeSortedActivities, and full position resequencing. This is still not constant-time:
73+ // the fast path continues to clone Maps and update sortedActivities, but it avoids the
74+ // broader recomputation required for the general case.
75+ if ( activityLivestreamingMetadata ) {
76+ const sessionId = activityLivestreamingMetadata . sessionId as LivestreamSessionId ;
77+ const existingSession = state . livestreamSessionMap . get ( sessionId ) ;
78+ const finalized = activityLivestreamingMetadata . type === 'final activity' ;
79+
80+ if (
81+ existingSession &&
82+ ! existingSession . finalized &&
83+ ! finalized &&
84+ ! getPartGroupingMetadataMap ( activity ) . has ( 'HowTo' )
85+ ) {
86+ // Defer all Map cloning until after the position-collision check succeeds.
87+ // First build the next session entry (needed to determine insertIndex), then
88+ // locate the insertion point in sortedActivities, compute the new position,
89+ // and only clone Maps when we know the fast path will be taken.
90+
91+ // 1. Compute the next session entry (needed to find insertIndex).
92+ // Timestamp is NOT updated for rev 2..N-1 (only for first and final).
93+ const nextSessionEntry : LivestreamSessionMapEntry = {
94+ activities : Object . freeze (
95+ insertSorted < LivestreamSessionMapEntryActivityEntry > (
96+ existingSession . activities ,
97+ Object . freeze ( {
98+ activityLocalId,
99+ logicalTimestamp,
100+ sequenceNumber : activityLivestreamingMetadata . sequenceNumber ,
101+ type : 'activity'
102+ } ) ,
103+ ( { sequenceNumber : x } , { sequenceNumber : y } ) =>
104+ typeof x === 'undefined' || typeof y === 'undefined'
105+ ? // eslint-disable-next-line no-magic-numbers
106+ - 1
107+ : x - y
108+ )
109+ ) ,
110+ finalized : false ,
111+ logicalTimestamp : existingSession . logicalTimestamp
112+ } ;
113+
114+ // 2. sortedActivities: find the insertion point before cloning anything.
115+ // The session's activities are sorted by sequence number via insertSorted.
116+ // Find where the new activity landed in that list and locate the correct
117+ // insertion point in sortedActivities relative to its session neighbors.
118+ const newIndexInSession = nextSessionEntry . activities . findIndex (
119+ entry => entry . activityLocalId === activityLocalId
120+ ) ;
121+
122+ const successorInSession =
123+ newIndexInSession + 1 < nextSessionEntry . activities . length
124+ ? nextSessionEntry . activities [ newIndexInSession + 1 ]
125+ : undefined ;
126+
127+ let insertIndex = state . sortedActivities . length ;
128+
129+ if ( successorInSession ) {
130+ // Insert before the successor activity in sortedActivities.
131+ for ( let i = 0 ; i < state . sortedActivities . length ; i ++ ) {
132+ // eslint-disable-next-line security/detect-object-injection
133+ if ( getLocalIdFromActivity ( state . sortedActivities [ i ] ! ) === successorInSession . activityLocalId ) {
134+ insertIndex = i ;
135+ break ;
136+ }
137+ }
138+ } else {
139+ // New activity is last in the session; insert after the previous last activity.
140+ // eslint-disable-next-line no-magic-numbers
141+ const prevLastSessionActivity = existingSession . activities . at ( - 1 ) ;
142+
143+ if ( prevLastSessionActivity ) {
144+ for ( let i = state . sortedActivities . length - 1 ; i >= 0 ; i -- ) {
145+ // eslint-disable-next-line security/detect-object-injection
146+ if ( getLocalIdFromActivity ( state . sortedActivities [ i ] ! ) === prevLastSessionActivity . activityLocalId ) {
147+ insertIndex = i + 1 ;
148+ break ;
149+ }
150+ }
151+ }
152+ }
153+
154+ // 3. Position: assign the new activity a position based on its neighbors.
155+ const prevPosition =
156+ insertIndex > 0 ? ( queryPositionFromActivity ( state . sortedActivities [ insertIndex - 1 ] ! ) ?? 0 ) : 0 ;
157+
158+ const nextSiblingPosition =
159+ insertIndex < state . sortedActivities . length
160+ ? queryPositionFromActivity ( state . sortedActivities [ + insertIndex ] ! )
161+ : undefined ;
162+
163+ let newPosition = prevPosition + POSITION_INCREMENT ;
164+
165+ // Squeeze if the default increment would collide with the next sibling.
166+ if ( typeof nextSiblingPosition !== 'undefined' && newPosition >= nextSiblingPosition ) {
167+ newPosition = prevPosition + 1 ;
168+ }
169+
170+ // If position is valid (no collision), clone Maps and return fast path result.
171+ // Otherwise fall through to slow path for full re-sequencing.
172+ if ( typeof nextSiblingPosition === 'undefined' || newPosition < nextSiblingPosition ) {
173+ const positionedActivity = setPositionInActivity ( activity , newPosition ) ;
174+
175+ // 4. activityIdToLocalIdMap: reuse if no activity.id, copy + add otherwise.
176+ let nextActivityIdToLocalIdMap = state . activityIdToLocalIdMap ;
177+
178+ if ( typeof activity . id !== 'undefined' ) {
179+ nextActivityIdToLocalIdMap = new Map ( state . activityIdToLocalIdMap ) ;
180+ nextActivityIdToLocalIdMap . set ( activity . id , activityLocalId ) ;
181+ }
182+
183+ // 5. activityMap: +1 entry with the positioned activity.
184+ const nextActivityMap = new Map ( state . activityMap ) ;
185+
186+ nextActivityMap . set (
187+ activityLocalId ,
188+ Object . freeze ( { activity : positionedActivity , activityLocalId, logicalTimestamp, type : 'activity' as const } )
189+ ) ;
190+
191+ // 6. clientActivityIdToLocalIdMap: reuse if no clientActivityID, copy + add otherwise.
192+ const { clientActivityID } = activity . channelData ;
193+ let nextClientActivityIdToLocalIdMap = state . clientActivityIdToLocalIdMap ;
194+
195+ if ( typeof clientActivityID !== 'undefined' ) {
196+ nextClientActivityIdToLocalIdMap = new Map ( state . clientActivityIdToLocalIdMap ) ;
197+ nextClientActivityIdToLocalIdMap . set ( clientActivityID , activityLocalId ) ;
198+ }
199+
200+ // 7. livestreamSessionMap: record the updated session.
201+ const nextLivestreamSessionMap = new Map ( state . livestreamSessionMap ) ;
202+
203+ nextLivestreamSessionMap . set ( sessionId , Object . freeze ( nextSessionEntry ) ) ;
204+
205+ return Object . freeze ( {
206+ activityIdToLocalIdMap : Object . freeze ( nextActivityIdToLocalIdMap ) ,
207+ activityMap : Object . freeze ( nextActivityMap ) ,
208+ clientActivityIdToLocalIdMap : Object . freeze ( nextClientActivityIdToLocalIdMap ) ,
209+ howToGroupingMap : state . howToGroupingMap ,
210+ livestreamSessionMap : Object . freeze ( nextLivestreamSessionMap ) ,
211+ sortedActivities : Object . freeze ( toSpliced ( state . sortedActivities , insertIndex , 0 , positionedActivity ) ) ,
212+ sortedChatHistoryList : state . sortedChatHistoryList
213+ } satisfies State ) ;
214+ }
215+ }
216+ }
217+
218+ // #endregion
219+
220+ // Slow path: full recalculation for non-streaming, first/final revisions, reorders, or HowTo grouping.
61221 const nextActivityIdToLocalIdMap = new Map ( state . activityIdToLocalIdMap ) ;
62222 const nextActivityMap = new Map ( state . activityMap ) ;
63223 const nextClientActivityIdToLocalIdMap = new Map ( state . clientActivityIdToLocalIdMap ) ;
64224 const nextLivestreamSessionMap = new Map ( state . livestreamSessionMap ) ;
65225 const nextHowToGroupingMap = new Map ( state . howToGroupingMap ) ;
66226 let nextSortedChatHistoryList = Array . from ( state . sortedChatHistoryList ) ;
67227
68- const activityLocalId = getLocalIdFromActivity ( activity ) ;
69- const logicalTimestamp = getLogicalTimestamp ( activity , ponyfill ) ;
70-
71228 if ( typeof activity . id !== 'undefined' ) {
72229 nextActivityIdToLocalIdMap . set ( activity . id , activityLocalId ) ;
73230 }
@@ -96,8 +253,6 @@ function upsert(ponyfill: Pick<GlobalScopePonyfill, 'Date'>, state: State, activ
96253
97254 // #region Livestreaming
98255
99- const activityLivestreamingMetadata = getActivityLivestreamingMetadata ( activity ) ;
100-
101256 if ( activityLivestreamingMetadata ) {
102257 const sessionId = activityLivestreamingMetadata . sessionId as LivestreamSessionId ;
103258
@@ -279,7 +434,6 @@ function upsert(ponyfill: Pick<GlobalScopePonyfill, 'Date'>, state: State, activ
279434 // #region Sequence sorted activities
280435
281436 let lastPosition = 0 ;
282- const POSITION_INCREMENT = 1_000 ;
283437
284438 for (
285439 let index = 0 , { length : nextSortedActivitiesLength } = nextSortedActivities ;
0 commit comments