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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/base/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 6 additions & 0 deletions packages/base/src/utils/toSpliced.ts
Original file line number Diff line number Diff line change
@@ -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<T>(array: readonly T[], start: number, deleteCount: number, ...items: T[]): T[] {
return coreJSToSpliced(array, start, deleteCount, ...items);
}
Original file line number Diff line number Diff line change
@@ -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<T>(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.
Expand Down
166 changes: 160 additions & 6 deletions packages/core/src/reducers/activities/sort/upsert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()),
Expand All @@ -58,16 +61,170 @@ const INITIAL_STATE = Object.freeze({
// - Duplicate timestamps: activities without timestamp can't be sort deterministically with quick sort

function upsert(ponyfill: Pick<GlobalScopePonyfill, 'Date'>, 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<LivestreamSessionMapEntryActivityEntry>(
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);
const nextLivestreamSessionMap = new Map(state.livestreamSessionMap);
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);
}
Expand Down Expand Up @@ -96,8 +253,6 @@ function upsert(ponyfill: Pick<GlobalScopePonyfill, 'Date'>, state: State, activ

// #region Livestreaming

const activityLivestreamingMetadata = getActivityLivestreamingMetadata(activity);

if (activityLivestreamingMetadata) {
const sessionId = activityLivestreamingMetadata.sessionId as LivestreamSessionId;

Expand Down Expand Up @@ -279,7 +434,6 @@ function upsert(ponyfill: Pick<GlobalScopePonyfill, 'Date'>, state: State, activ
// #region Sequence sorted activities

let lastPosition = 0;
const POSITION_INCREMENT = 1_000;

for (
let index = 0, { length: nextSortedActivitiesLength } = nextSortedActivities;
Expand Down
Loading