From 9063b9df3bc16e34a41dddcb1bebb9e1db6c4a31 Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 3 Apr 2026 16:01:47 +0000 Subject: [PATCH 1/6] perf: upsert fast path --- packages/base/src/utils/index.ts | 1 + packages/base/src/utils/toSpliced.ts | 6 + .../activities/sort/private/insertSorted.ts | 8 +- .../src/reducers/activities/sort/upsert.ts | 142 +++++++++++++++++- 4 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 packages/base/src/utils/toSpliced.ts diff --git a/packages/base/src/utils/index.ts b/packages/base/src/utils/index.ts index b8c0a3242f..323c734d7a 100644 --- a/packages/base/src/utils/index.ts +++ b/packages/base/src/utils/index.ts @@ -4,5 +4,6 @@ export { default as isForbiddenPropertyName } from './isForbiddenPropertyName'; export { default as iterateEquals } from './iterateEquals'; export { type OneOrMany } from './OneOrMany'; export { default as singleToArray } from './singleToArray'; +export { default as toSpliced } from './toSpliced'; export { default as warnOnce } from './warnOnce'; export { default as withResolvers, type PromiseWithResolvers } from './withResolvers'; diff --git a/packages/base/src/utils/toSpliced.ts b/packages/base/src/utils/toSpliced.ts new file mode 100644 index 0000000000..e19340d7a3 --- /dev/null +++ b/packages/base/src/utils/toSpliced.ts @@ -0,0 +1,6 @@ +// @ts-expect-error: no types available +import coreJSToSpliced from 'core-js-pure/features/array/to-spliced.js'; + +export default function toSpliced(array: readonly T[], start: number, deleteCount: number, ...items: T[]): T[] { + return coreJSToSpliced(array, start, deleteCount, ...items); +} diff --git a/packages/core/src/reducers/activities/sort/private/insertSorted.ts b/packages/core/src/reducers/activities/sort/private/insertSorted.ts index 766554a600..77dd5f4f7c 100644 --- a/packages/core/src/reducers/activities/sort/private/insertSorted.ts +++ b/packages/core/src/reducers/activities/sort/private/insertSorted.ts @@ -1,10 +1,4 @@ -// @ts-ignore No @types/core-js-pure -import { default as toSpliced_ } from 'core-js-pure/features/array/to-spliced.js'; - -// The Node.js version we are using for CI/CD does not support Array.prototype.toSpliced yet. -function toSpliced(array: readonly T[], start: number, deleteCount: number, ...items: T[]): T[] { - return toSpliced_(array, start, deleteCount, ...items); -} +import { toSpliced } from '@msinternal/botframework-webchat-base/utils'; /** * Inserts a single item into a sorted array. diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts index c917d77568..336912984f 100644 --- a/packages/core/src/reducers/activities/sort/upsert.ts +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -6,6 +6,7 @@ import computeSortedActivities from './private/computeSortedActivities'; import getLogicalTimestamp from './private/getLogicalTimestamp'; import getPartGroupingMetadataMap from './private/getPartGroupingMetadataMap'; import insertSorted from './private/insertSorted'; +import { toSpliced } from '@msinternal/botframework-webchat-base/utils'; import { getLocalIdFromActivity } from './property/LocalId'; import { queryPositionFromActivity, setPositionInActivity } from './property/Position'; import { @@ -41,6 +42,8 @@ import { // - Always copy timestamp, except when it's a livestream of 2...N-1 revision // - Part grouping timestamp is copied from upserting entry (either livestream session or activity) +const POSITION_INCREMENT = 1_000; + const INITIAL_STATE = Object.freeze({ activityIdToLocalIdMap: Object.freeze(new Map()), activityMap: Object.freeze(new Map()), @@ -58,6 +61,139 @@ const INITIAL_STATE = Object.freeze({ // - Duplicate timestamps: activities without timestamp can't be sort deterministically with quick sort function upsert(ponyfill: Pick, state: State, activity: Activity): State { + const activityLocalId = getLocalIdFromActivity(activity); + const logicalTimestamp = getLogicalTimestamp(activity, ponyfill); + const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity); + + // #region Streaming fast path + // + // For revision 2..N-1 of an existing, non-finalized livestream session without HowTo grouping: + // skip O(n) Map copies, sortedChatHistoryList recomputation, computeSortedActivities, and + // full position sequencing. This turns each streaming revision from O(n) to O(session_revisions). + if (activityLivestreamingMetadata) { + const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId; + const existingSession = state.livestreamSessionMap.get(sessionId); + const finalized = activityLivestreamingMetadata.type === 'final activity'; + + if ( + existingSession && + !existingSession.finalized && + !finalized && + !getPartGroupingMetadataMap(activity).has('HowTo') + ) { + // 1. activityIdToLocalIdMap: +1 entry for activity.id (if present). + const nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap); + + if (typeof activity.id !== 'undefined') { + nextActivityIdToLocalIdMap.set(activity.id, activityLocalId); + } + + // 2. activityMap: +1 entry. + const nextActivityMap = new Map(state.activityMap); + + nextActivityMap.set( + activityLocalId, + Object.freeze({ activity, activityLocalId, logicalTimestamp, type: 'activity' as const }) + ); + + // 3. clientActivityIdToLocalIdMap: reuse if no clientActivityID, copy + add otherwise. + const { clientActivityID } = activity.channelData; + let nextClientActivityIdToLocalIdMap = state.clientActivityIdToLocalIdMap; + + if (typeof clientActivityID !== 'undefined') { + nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap); + nextClientActivityIdToLocalIdMap.set(clientActivityID, activityLocalId); + } + + // 4. livestreamSessionMap: append revision to the existing session. + // Timestamp is NOT updated for rev 2..N-1 (only for first and final). + const nextLivestreamSessionMap = new Map(state.livestreamSessionMap); + + const nextSessionEntry: LivestreamSessionMapEntry = { + activities: Object.freeze( + insertSorted( + existingSession.activities, + Object.freeze({ + activityLocalId, + logicalTimestamp, + sequenceNumber: activityLivestreamingMetadata.sequenceNumber, + type: 'activity' + }), + ({ sequenceNumber: x }, { sequenceNumber: y }) => + typeof x === 'undefined' || typeof y === 'undefined' + ? // eslint-disable-next-line no-magic-numbers + -1 + : x - y + ) + ), + finalized: false, + logicalTimestamp: existingSession.logicalTimestamp + }; + + nextLivestreamSessionMap.set(sessionId, Object.freeze(nextSessionEntry)); + + // 5. sortedActivities: insert the new revision into the session's block. + // Find where the session's last activity lives in the sorted array and splice after it. + // eslint-disable-next-line no-magic-numbers + const prevLastSessionActivity = existingSession.activities.at(-1); + let insertIndex = state.sortedActivities.length; + + if (prevLastSessionActivity) { + for (let i = state.sortedActivities.length - 1; i >= 0; i--) { + // eslint-disable-next-line security/detect-object-injection + if (getLocalIdFromActivity(state.sortedActivities[i]!) === prevLastSessionActivity.activityLocalId) { + insertIndex = i + 1; + break; + } + } + } + + // 6. Position: assign the new activity a position based on its neighbors. + const prevPosition = + insertIndex > 0 ? (queryPositionFromActivity(state.sortedActivities[insertIndex - 1]!) ?? 0) : 0; + + const nextSiblingPosition = + insertIndex < state.sortedActivities.length + ? queryPositionFromActivity(state.sortedActivities[+insertIndex]!) + : undefined; + + let newPosition = prevPosition + POSITION_INCREMENT; + + // Squeeze if the default increment would collide with the next sibling. + if (typeof nextSiblingPosition !== 'undefined' && newPosition >= nextSiblingPosition) { + newPosition = prevPosition + 1; + } + + // If position is valid (no collision), return fast path result. + // Otherwise fall through to slow path for full re-sequencing. + if (typeof nextSiblingPosition === 'undefined' || newPosition < nextSiblingPosition) { + const positionedActivity = setPositionInActivity(activity, newPosition); + + const positionedEntry: ActivityMapEntry = Object.freeze({ + activity: positionedActivity, + activityLocalId, + logicalTimestamp, + type: 'activity' + }); + + nextActivityMap.set(activityLocalId, positionedEntry); + + return Object.freeze({ + activityIdToLocalIdMap: Object.freeze(nextActivityIdToLocalIdMap), + activityMap: Object.freeze(nextActivityMap), + clientActivityIdToLocalIdMap: Object.freeze(nextClientActivityIdToLocalIdMap), + howToGroupingMap: state.howToGroupingMap, + livestreamSessionMap: Object.freeze(nextLivestreamSessionMap), + sortedActivities: Object.freeze(toSpliced(state.sortedActivities, insertIndex, 0, positionedActivity)), + sortedChatHistoryList: state.sortedChatHistoryList + } satisfies State); + } + } + } + + // #endregion + + // Slow path: full recalculation for non-streaming, first/final revisions, reorders, or HowTo grouping. const nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap); const nextActivityMap = new Map(state.activityMap); const nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap); @@ -65,9 +201,6 @@ function upsert(ponyfill: Pick, state: State, activ const nextHowToGroupingMap = new Map(state.howToGroupingMap); let nextSortedChatHistoryList = Array.from(state.sortedChatHistoryList); - const activityLocalId = getLocalIdFromActivity(activity); - const logicalTimestamp = getLogicalTimestamp(activity, ponyfill); - if (typeof activity.id !== 'undefined') { nextActivityIdToLocalIdMap.set(activity.id, activityLocalId); } @@ -96,8 +229,6 @@ function upsert(ponyfill: Pick, state: State, activ // #region Livestreaming - const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity); - if (activityLivestreamingMetadata) { const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId; @@ -279,7 +410,6 @@ function upsert(ponyfill: Pick, state: State, activ // #region Sequence sorted activities let lastPosition = 0; - const POSITION_INCREMENT = 1_000; for ( let index = 0, { length: nextSortedActivitiesLength } = nextSortedActivities; From 2b01003016dc3d62d347cde88d6c93e056711a8f Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 3 Apr 2026 16:16:40 +0000 Subject: [PATCH 2/6] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8492bc4485..415441f179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,7 @@ Breaking changes in this release: ### Changed +- Added streaming fast path in activity upsert to skip recomputation for mid-stream revisions, in PR [#5796](https://github.com/microsoft/BotFramework-WebChat/pull/5796), by [@OEvgeny](https://github.com/OEvgeny) - Updated `useSuggestedActions` to return the activity the suggested actions originated from, in PR [#5255](https://github.com/microsoft/BotFramework-WebChat/issues/5255), by [@compulim](https://github.com/compulim) - Improved focus trap implementation by preserving focus state and removing sentinels, in PR [#5243](https://github.com/microsoft/BotFramework-WebChat/pull/5243), by [@OEvgeny](https://github.com/OEvgeny) - Reworked pre-chat activity layout to use author entity for improved consistency and flexibility, in PR [#5274](https://github.com/microsoft/BotFramework-WebChat/pull/5274), by [@OEvgeny](https://github.com/OEvgeny) From 7e5769c33f639bad41645128481b33b6479eca4b Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 3 Apr 2026 16:37:09 +0000 Subject: [PATCH 3/6] Fix tests --- .../src/reducers/activities/sort/upsert.ts | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts index 336912984f..cbbcccb10b 100644 --- a/packages/core/src/reducers/activities/sort/upsert.ts +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -133,19 +133,43 @@ function upsert(ponyfill: Pick, state: State, activ nextLivestreamSessionMap.set(sessionId, Object.freeze(nextSessionEntry)); // 5. sortedActivities: insert the new revision into the session's block. - // Find where the session's last activity lives in the sorted array and splice after it. - // eslint-disable-next-line no-magic-numbers - const prevLastSessionActivity = existingSession.activities.at(-1); + // The session's activities are sorted by sequence number via insertSorted. + // Find where the new activity landed in that list and locate the correct + // insertion point in sortedActivities relative to its session neighbors. + const newIndexInSession = nextSessionEntry.activities.findIndex( + entry => entry.activityLocalId === activityLocalId + ); + + const successorInSession = + newIndexInSession + 1 < nextSessionEntry.activities.length + ? nextSessionEntry.activities[newIndexInSession + 1] + : undefined; + let insertIndex = state.sortedActivities.length; - if (prevLastSessionActivity) { - for (let i = state.sortedActivities.length - 1; i >= 0; i--) { + if (successorInSession) { + // Insert before the successor activity in sortedActivities. + for (let i = 0; i < state.sortedActivities.length; i++) { // eslint-disable-next-line security/detect-object-injection - if (getLocalIdFromActivity(state.sortedActivities[i]!) === prevLastSessionActivity.activityLocalId) { - insertIndex = i + 1; + if (getLocalIdFromActivity(state.sortedActivities[i]!) === successorInSession.activityLocalId) { + insertIndex = i; break; } } + } else { + // New activity is last in the session; insert after the previous last activity. + // eslint-disable-next-line no-magic-numbers + const prevLastSessionActivity = existingSession.activities.at(-1); + + if (prevLastSessionActivity) { + for (let i = state.sortedActivities.length - 1; i >= 0; i--) { + // eslint-disable-next-line security/detect-object-injection + if (getLocalIdFromActivity(state.sortedActivities[i]!) === prevLastSessionActivity.activityLocalId) { + insertIndex = i + 1; + break; + } + } + } } // 6. Position: assign the new activity a position based on its neighbors. From 76d6b3339317a84d9670b7ed837e1cae9fb32db6 Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 3 Apr 2026 16:50:57 +0000 Subject: [PATCH 4/6] Update packages/core/src/reducers/activities/sort/upsert.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/core/src/reducers/activities/sort/upsert.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts index cbbcccb10b..d5b4cdd663 100644 --- a/packages/core/src/reducers/activities/sort/upsert.ts +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -68,8 +68,10 @@ function upsert(ponyfill: Pick, state: State, activ // #region Streaming fast path // // For revision 2..N-1 of an existing, non-finalized livestream session without HowTo grouping: - // skip O(n) Map copies, sortedChatHistoryList recomputation, computeSortedActivities, and - // full position sequencing. This turns each streaming revision from O(n) to O(session_revisions). + // avoid the heavier full rebuild path, including sortedChatHistoryList recomputation, + // computeSortedActivities, and full position resequencing. This is still not constant-time: + // the fast path continues to clone Maps and update sortedActivities, but it avoids the + // broader recomputation required for the general case. if (activityLivestreamingMetadata) { const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId; const existingSession = state.livestreamSessionMap.get(sessionId); From a8a75989b4ee6b268817ccbb420f37ef7f5b5d5b Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 3 Apr 2026 16:53:41 +0000 Subject: [PATCH 5/6] Update packages/core/src/reducers/activities/sort/upsert.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/core/src/reducers/activities/sort/upsert.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts index d5b4cdd663..f0e5451c7b 100644 --- a/packages/core/src/reducers/activities/sort/upsert.ts +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -83,10 +83,11 @@ function upsert(ponyfill: Pick, state: State, activ !finalized && !getPartGroupingMetadataMap(activity).has('HowTo') ) { - // 1. activityIdToLocalIdMap: +1 entry for activity.id (if present). - const nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap); + // 1. activityIdToLocalIdMap: reuse if no activity.id, copy + add otherwise. + let nextActivityIdToLocalIdMap = state.activityIdToLocalIdMap; if (typeof activity.id !== 'undefined') { + nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap); nextActivityIdToLocalIdMap.set(activity.id, activityLocalId); } From d2961c3f90f7012cb6ee7fbc57908d8b9459ba08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:58:18 +0000 Subject: [PATCH 6/6] refactor: defer Map cloning in upsert fast path until after collision check Agent-Logs-Url: https://github.com/microsoft/BotFramework-WebChat/sessions/30b9dd7c-b976-4ae3-9ea3-79247db38e40 Co-authored-by: OEvgeny <2841858+OEvgeny@users.noreply.github.com> --- .../src/reducers/activities/sort/upsert.ts | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/packages/core/src/reducers/activities/sort/upsert.ts b/packages/core/src/reducers/activities/sort/upsert.ts index f0e5451c7b..a0ee2d8ded 100644 --- a/packages/core/src/reducers/activities/sort/upsert.ts +++ b/packages/core/src/reducers/activities/sort/upsert.ts @@ -83,35 +83,13 @@ function upsert(ponyfill: Pick, state: State, activ !finalized && !getPartGroupingMetadataMap(activity).has('HowTo') ) { - // 1. activityIdToLocalIdMap: reuse if no activity.id, copy + add otherwise. - let nextActivityIdToLocalIdMap = state.activityIdToLocalIdMap; + // Defer all Map cloning until after the position-collision check succeeds. + // First build the next session entry (needed to determine insertIndex), then + // locate the insertion point in sortedActivities, compute the new position, + // and only clone Maps when we know the fast path will be taken. - if (typeof activity.id !== 'undefined') { - nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap); - nextActivityIdToLocalIdMap.set(activity.id, activityLocalId); - } - - // 2. activityMap: +1 entry. - const nextActivityMap = new Map(state.activityMap); - - nextActivityMap.set( - activityLocalId, - Object.freeze({ activity, activityLocalId, logicalTimestamp, type: 'activity' as const }) - ); - - // 3. clientActivityIdToLocalIdMap: reuse if no clientActivityID, copy + add otherwise. - const { clientActivityID } = activity.channelData; - let nextClientActivityIdToLocalIdMap = state.clientActivityIdToLocalIdMap; - - if (typeof clientActivityID !== 'undefined') { - nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap); - nextClientActivityIdToLocalIdMap.set(clientActivityID, activityLocalId); - } - - // 4. livestreamSessionMap: append revision to the existing session. + // 1. Compute the next session entry (needed to find insertIndex). // Timestamp is NOT updated for rev 2..N-1 (only for first and final). - const nextLivestreamSessionMap = new Map(state.livestreamSessionMap); - const nextSessionEntry: LivestreamSessionMapEntry = { activities: Object.freeze( insertSorted( @@ -133,9 +111,7 @@ function upsert(ponyfill: Pick, state: State, activ logicalTimestamp: existingSession.logicalTimestamp }; - nextLivestreamSessionMap.set(sessionId, Object.freeze(nextSessionEntry)); - - // 5. sortedActivities: insert the new revision into the session's block. + // 2. sortedActivities: find the insertion point before cloning anything. // The session's activities are sorted by sequence number via insertSorted. // Find where the new activity landed in that list and locate the correct // insertion point in sortedActivities relative to its session neighbors. @@ -175,7 +151,7 @@ function upsert(ponyfill: Pick, state: State, activ } } - // 6. Position: assign the new activity a position based on its neighbors. + // 3. Position: assign the new activity a position based on its neighbors. const prevPosition = insertIndex > 0 ? (queryPositionFromActivity(state.sortedActivities[insertIndex - 1]!) ?? 0) : 0; @@ -191,19 +167,40 @@ function upsert(ponyfill: Pick, state: State, activ newPosition = prevPosition + 1; } - // If position is valid (no collision), return fast path result. + // If position is valid (no collision), clone Maps and return fast path result. // Otherwise fall through to slow path for full re-sequencing. if (typeof nextSiblingPosition === 'undefined' || newPosition < nextSiblingPosition) { const positionedActivity = setPositionInActivity(activity, newPosition); - const positionedEntry: ActivityMapEntry = Object.freeze({ - activity: positionedActivity, + // 4. activityIdToLocalIdMap: reuse if no activity.id, copy + add otherwise. + let nextActivityIdToLocalIdMap = state.activityIdToLocalIdMap; + + if (typeof activity.id !== 'undefined') { + nextActivityIdToLocalIdMap = new Map(state.activityIdToLocalIdMap); + nextActivityIdToLocalIdMap.set(activity.id, activityLocalId); + } + + // 5. activityMap: +1 entry with the positioned activity. + const nextActivityMap = new Map(state.activityMap); + + nextActivityMap.set( activityLocalId, - logicalTimestamp, - type: 'activity' - }); + Object.freeze({ activity: positionedActivity, activityLocalId, logicalTimestamp, type: 'activity' as const }) + ); + + // 6. clientActivityIdToLocalIdMap: reuse if no clientActivityID, copy + add otherwise. + const { clientActivityID } = activity.channelData; + let nextClientActivityIdToLocalIdMap = state.clientActivityIdToLocalIdMap; + + if (typeof clientActivityID !== 'undefined') { + nextClientActivityIdToLocalIdMap = new Map(state.clientActivityIdToLocalIdMap); + nextClientActivityIdToLocalIdMap.set(clientActivityID, activityLocalId); + } + + // 7. livestreamSessionMap: record the updated session. + const nextLivestreamSessionMap = new Map(state.livestreamSessionMap); - nextActivityMap.set(activityLocalId, positionedEntry); + nextLivestreamSessionMap.set(sessionId, Object.freeze(nextSessionEntry)); return Object.freeze({ activityIdToLocalIdMap: Object.freeze(nextActivityIdToLocalIdMap),