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) 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..a0ee2d8ded 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,163 @@ 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: + // 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); + const finalized = activityLivestreamingMetadata.type === 'final activity'; + + if ( + existingSession && + !existingSession.finalized && + !finalized && + !getPartGroupingMetadataMap(activity).has('HowTo') + ) { + // 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. + + // 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 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 + }; + + // 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. + 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 (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]!) === 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; + } + } + } + } + + // 3. 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), 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); + + // 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, + 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); + + nextLivestreamSessionMap.set(sessionId, Object.freeze(nextSessionEntry)); + + 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 +225,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 +253,6 @@ function upsert(ponyfill: Pick, state: State, activ // #region Livestreaming - const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity); - if (activityLivestreamingMetadata) { const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId; @@ -279,7 +434,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;