@microsoft/omnichannel-chat-widget 2.0.1-main.84e2c02 → 2.0.1-main.c997354

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -143,6 +143,8 @@ let TelemetryEvent = exports.TelemetryEvent = /*#__PURE__*/function (TelemetryEv
143
143
  TelemetryEvent["PrechatSubmitted"] = "PrechatSubmitted";
144
144
  TelemetryEvent["StartChatSDKCall"] = "StartChatCall";
145
145
  TelemetryEvent["StartChatEventReceived"] = "StartChatEventReceived";
146
+ TelemetryEvent["ConcurrentStartChatRejected"] = "ConcurrentStartChatRejected";
147
+ TelemetryEvent["ChatSDKStateCleanUpDisconnectFailed"] = "ChatSDKStateCleanUpDisconnectFailed";
146
148
  TelemetryEvent["EndChatSDKCall"] = "EndChatSDKCall";
147
149
  TelemetryEvent["PrepareEndChat"] = "PrepareEndChat";
148
150
  TelemetryEvent["EndChatEventReceived"] = "EndChatEventReceived";
@@ -415,6 +417,8 @@ let TelemetryConstants = exports.TelemetryConstants = /*#__PURE__*/function () {
415
417
  case TelemetryEvent.SecureEventBusDispatchError:
416
418
  case TelemetryEvent.AgentJoinedConversation:
417
419
  case TelemetryEvent.BrowserTabHidden:
420
+ case TelemetryEvent.ConcurrentStartChatRejected:
421
+ case TelemetryEvent.ChatSDKStateCleanUpDisconnectFailed:
418
422
  case TelemetryEvent.RecoveryEligibilityEvaluated:
419
423
  return ScenarioType.ACTIONS;
420
424
  case TelemetryEvent.StartChatSDKCall:
@@ -58,37 +58,54 @@ const ChatButtonStateful = props => {
58
58
  const ref = (0, _react.useRef)(() => {
59
59
  return;
60
60
  });
61
+ // Optimistic guard against rapid double-clicks. `conversationState` only flips to
62
+ // `Loading` after React re-renders, so the button stays visible and clickable for the
63
+ // whole duration of the first click's async work. Without this ref two concurrent
64
+ // startChat() calls can create two conversations on the backend.
65
+ const startChatInProgressRef = (0, _react.useRef)(false);
61
66
  ref.current = async () => {
62
- _TelemetryHelper.TelemetryHelper.logActionEventToAllTelemetry(_TelemetryConstants.LogLevel.INFO, {
63
- Event: _TelemetryConstants.TelemetryEvent.LCWChatButtonClicked,
64
- Description: "Chat button click action started",
65
- CustomProperties: {
66
- ConversationStage: _TelemetryConstants.ConversationStage.Initialization
67
- }
68
- });
69
- if (state.appStates.isMinimized) {
70
- dispatch({
71
- type: _LiveChatWidgetActionType.LiveChatWidgetActionType.SET_MINIMIZED,
72
- payload: false
67
+ if (startChatInProgressRef.current) {
68
+ _TelemetryHelper.TelemetryHelper.logActionEvent(_TelemetryConstants.LogLevel.WARN, {
69
+ Event: _TelemetryConstants.TelemetryEvent.ConcurrentStartChatRejected,
70
+ Description: "Chat button click ignored, a start chat operation is already in progress."
73
71
  });
74
- dispatch({
75
- type: _LiveChatWidgetActionType.LiveChatWidgetActionType.SET_UNREAD_MESSAGE_COUNT,
76
- payload: 0
72
+ return;
73
+ }
74
+ startChatInProgressRef.current = true;
75
+ try {
76
+ _TelemetryHelper.TelemetryHelper.logActionEventToAllTelemetry(_TelemetryConstants.LogLevel.INFO, {
77
+ Event: _TelemetryConstants.TelemetryEvent.LCWChatButtonClicked,
78
+ Description: "Chat button click action started",
79
+ CustomProperties: {
80
+ ConversationStage: _TelemetryConstants.ConversationStage.Initialization
81
+ }
77
82
  });
78
- // If chat is minimized and then unminimized, start a chat if convesation state is closed.
79
- if (state.appStates.conversationState === _ConversationState.ConversationState.Closed) {
83
+ if (state.appStates.isMinimized) {
84
+ dispatch({
85
+ type: _LiveChatWidgetActionType.LiveChatWidgetActionType.SET_MINIMIZED,
86
+ payload: false
87
+ });
88
+ dispatch({
89
+ type: _LiveChatWidgetActionType.LiveChatWidgetActionType.SET_UNREAD_MESSAGE_COUNT,
90
+ payload: 0
91
+ });
92
+ // If chat is minimized and then unminimized, start a chat if convesation state is closed.
93
+ if (state.appStates.conversationState === _ConversationState.ConversationState.Closed) {
94
+ await startChat();
95
+ }
96
+ } else {
80
97
  await startChat();
81
98
  }
82
- } else {
83
- await startChat();
99
+ _TelemetryHelper.TelemetryHelper.logActionEventToAllTelemetry(_TelemetryConstants.LogLevel.INFO, {
100
+ Event: _TelemetryConstants.TelemetryEvent.LCWChatButtonActionCompleted,
101
+ Description: "Chat button action completed",
102
+ CustomProperties: {
103
+ ConversationStage: _TelemetryConstants.ConversationStage.Initialization
104
+ }
105
+ });
106
+ } finally {
107
+ startChatInProgressRef.current = false;
84
108
  }
85
- _TelemetryHelper.TelemetryHelper.logActionEventToAllTelemetry(_TelemetryConstants.LogLevel.INFO, {
86
- Event: _TelemetryConstants.TelemetryEvent.LCWChatButtonActionCompleted,
87
- Description: "Chat button action completed",
88
- CustomProperties: {
89
- ConversationStage: _TelemetryConstants.ConversationStage.Initialization
90
- }
91
- });
92
109
  };
93
110
  const outOfOfficeStyleProps = Object.assign({}, _defaultOutOfOfficeChatButtonStyleProps.defaultOutOfOfficeChatButtonStyleProps, outOfOfficeButtonProps === null || outOfOfficeButtonProps === void 0 ? void 0 : outOfOfficeButtonProps.styleProps);
94
111
  const controlProps = {
@@ -360,13 +360,52 @@ const closeChatStateCleanUp = dispatch => {
360
360
 
361
361
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
362
362
  exports.closeChatStateCleanUp = closeChatStateCleanUp;
363
+ const logCleanUpDisconnectFailure = error => {
364
+ _TelemetryHelper.TelemetryHelper.logActionEvent(_TelemetryConstants.LogLevel.WARN, {
365
+ Event: _TelemetryConstants.TelemetryEvent.ChatSDKStateCleanUpDisconnectFailed,
366
+ Description: "Failed to disconnect the previous conversation during chat SDK state clean up.",
367
+ ExceptionDetails: {
368
+ exception: (error === null || error === void 0 ? void 0 : error.message) ?? error
369
+ }
370
+ });
371
+ };
372
+
373
+ // Releases the previous session's conversation. Mirrors the cleanup OmnichannelChatSDK.endChat()
374
+ // runs in its finally block (disconnect, then drop the reference). Without it, a conversation
375
+ // left over from a failed or orphaned start chat, or from a chat ended in another tab (which
376
+ // skips the SDK endChat), keeps its messaging session open and leaks into the next chat.
377
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
378
+ const disconnectConversation = chatSDK => {
379
+ const conversation = chatSDK.conversation;
380
+ if (!conversation || typeof conversation.disconnect !== "function") {
381
+ return;
382
+ }
383
+ try {
384
+ // ACS conversations disconnect asynchronously; legacy IC3 conversations may not return a promise.
385
+ const result = conversation.disconnect();
386
+ if (result && typeof result.catch === "function") {
387
+ result.catch(logCleanUpDisconnectFailure);
388
+ }
389
+ } catch (error) {
390
+ logCleanUpDisconnectFailure(error);
391
+ }
392
+ };
393
+
394
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
363
395
  const chatSDKStateCleanUp = chatSDK => {
396
+ if (!chatSDK) {
397
+ return;
398
+ }
399
+
364
400
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
365
401
  chatSDK.requestId = (0, _omnichannelChatSdk.uuidv4)();
366
402
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
367
403
  chatSDK.chatToken = {};
368
404
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
369
405
  chatSDK.reconnectId = null;
406
+ disconnectConversation(chatSDK);
407
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
408
+ chatSDK.conversation = null;
370
409
  };
371
410
 
372
411
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -24,18 +24,87 @@ var _authHelper = require("./authHelper");
24
24
  var _setPostChatContextAndLoadSurvey = require("./setPostChatContextAndLoadSurvey");
25
25
  var _persistentChatHelper = require("./persistentChatHelper");
26
26
  var _updateSessionDataForTelemetry = require("./updateSessionDataForTelemetry");
27
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
- let optionalParams = {};
29
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
- let widgetInstanceId;
31
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
- let popoutWidgetInstanceId;
27
+ /**
28
+ * Per widget instance mutexes for the two chat creation entry points.
29
+ *
30
+ * Every guard in the prepareStartChat -> setPreChatAndInitiateChat -> initStartChat chain
31
+ * checks `conversationState === Closed`, but React state is updated asynchronously. A second
32
+ * invocation fired within the same render cycle (for example a rapid double click on the chat
33
+ * button, or a chat button click racing a BroadcastEvent.StartChat) therefore still observes
34
+ * the stale `Closed` state, reaches `facadeChatSDK.startChat()` and creates a second
35
+ * conversation on the backend which is then orphaned and never ended.
36
+ *
37
+ * The locks are keyed by FacadeChatSDK instance rather than being module wide, because a host
38
+ * page can mount several independent widgets, each with its own widgetInstanceId and its own
39
+ * FacadeChatSDK (created once per widget in LiveChatWidget.tsx). A module wide lock would make
40
+ * widget A's start chat silently drop widget B's legitimate simultaneous start chat.
41
+ *
42
+ * The two maps are separate because prepareStartChat calls initStartChat; sharing a single lock
43
+ * would make the nested call see its own parent as in flight and drop it. prepareStartChat is
44
+ * additionally blocked by an in flight initStartChat (pre-chat submit, reconnect, or the
45
+ * hideStartChatButton bootstrap call initStartChat directly). Otherwise a later prepareStartChat
46
+ * would dispatch Loading and maximize the widget, and only then have its nested initStartChat
47
+ * dropped.
48
+ */
49
+ const prepareStartChatGuards = new WeakMap();
50
+ const initStartChatGuards = new WeakMap();
51
+
52
+ // Fallback owner used only when no FacadeChatSDK instance is available to key on, so the guard
53
+ // degrades to the module wide behaviour instead of disappearing altogether.
54
+ const unkeyedGuardOwner = {};
55
+ const getGuard = (guards, facadeChatSDK) => {
56
+ const owner = facadeChatSDK ?? unkeyedGuardOwner;
57
+ let guard = guards.get(owner);
58
+ if (!guard) {
59
+ guard = {
60
+ inFlight: false
61
+ };
62
+ guards.set(owner, guard);
63
+ }
64
+ return guard;
65
+ };
66
+
67
+ /**
68
+ * Runs `operation` only if no other invocation for the same widget instance is already in
69
+ * flight. Concurrent invocations are dropped (and reported through telemetry) rather than
70
+ * awaiting the in-flight promise, so a future re-entrant call can never deadlock on itself.
71
+ * No caller acts on the resolved value, so dropping is safe.
72
+ *
73
+ * `blockedBy` lets an operation also yield to a different, already running operation for the
74
+ * same widget instance without claiming that operation's lock.
75
+ */
76
+ const runExclusively = async (guards, facadeChatSDK, description, operation, props, blockedBy) => {
77
+ const guard = getGuard(guards, facadeChatSDK);
78
+ const blockingGuard = blockedBy ? getGuard(blockedBy, facadeChatSDK) : undefined;
79
+ if (guard.inFlight || blockingGuard !== null && blockingGuard !== void 0 && blockingGuard.inFlight) {
80
+ _TelemetryHelper.TelemetryHelper.logActionEvent(_TelemetryConstants.LogLevel.WARN, {
81
+ Event: _TelemetryConstants.TelemetryEvent.ConcurrentStartChatRejected,
82
+ Description: description,
83
+ CustomProperties: {
84
+ WidgetCacheId: props ? (0, _utils.getWidgetCacheIdfromProps)(props) : undefined
85
+ }
86
+ });
87
+ return;
88
+ }
89
+
90
+ // Claim the lock before invoking the operation, so a re-entrant call made from the
91
+ // operation's synchronous preamble (before its first await) cannot slip past the check above.
92
+ guard.inFlight = true;
93
+ try {
94
+ await operation();
95
+ } finally {
96
+ guard.inFlight = false;
97
+ }
98
+ };
33
99
 
34
100
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
101
  const prepareStartChat = async (props, facadeChatSDK, state, dispatch, setAdapter) => {
36
- optionalParams = {}; //Resetting to ensure no stale values
37
- widgetInstanceId = (0, _utils.getWidgetCacheIdfromProps)(props);
102
+ return runExclusively(prepareStartChatGuards, facadeChatSDK, "Concurrent prepareStartChat call ignored, a start chat operation is already in progress for this widget instance.", () => prepareStartChatInternal(props, facadeChatSDK, state, dispatch, setAdapter), props, initStartChatGuards);
103
+ };
38
104
 
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ exports.prepareStartChat = prepareStartChat;
107
+ const prepareStartChatInternal = async (props, facadeChatSDK, state, dispatch, setAdapter) => {
39
108
  // reconnect > chat from cache
40
109
  if ((0, _reconnectChatHelper.isReconnectEnabled)(props.chatConfig) === true && !(0, _reconnectChatHelper.isPersistentEnabled)(props.chatConfig)) {
41
110
  const shouldStartChatNormally = await (0, _reconnectChatHelper.handleChatReconnect)(facadeChatSDK, props, dispatch, setAdapter, initStartChat, state);
@@ -63,7 +132,6 @@ const prepareStartChat = async (props, facadeChatSDK, state, dispatch, setAdapte
63
132
  };
64
133
 
65
134
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
- exports.prepareStartChat = prepareStartChat;
67
135
  const setPreChatAndInitiateChat = async (facadeChatSDK, dispatch, setAdapter, isProactiveChat, proactiveChatEnablePrechatState, state, props) => {
68
136
  var _props$preChatSurveyP, _props$controlProps, _state$domainStates, _state$appStates2;
69
137
  // Reset before loading prechat to avoid starting chat with previous requestId
@@ -163,6 +231,12 @@ const setPreChatAndInitiateChat = async (facadeChatSDK, dispatch, setAdapter, is
163
231
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
232
  exports.setPreChatAndInitiateChat = setPreChatAndInitiateChat;
165
233
  const initStartChat = async (facadeChatSDK, dispatch, setAdapter, state, props, params, persistedState) => {
234
+ return runExclusively(initStartChatGuards, facadeChatSDK, "Concurrent initStartChat call ignored, a start chat operation is already in progress for this widget instance.", () => initStartChatInternal(facadeChatSDK, dispatch, setAdapter, state, props, params, persistedState), props);
235
+ };
236
+
237
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
+ exports.initStartChat = initStartChat;
239
+ const initStartChatInternal = async (facadeChatSDK, dispatch, setAdapter, state, props, params, persistedState) => {
166
240
  var _state$domainStates7;
167
241
  let isStartChatSuccessful = false;
168
242
  const persistentChatEnabled = (0, _liveChatConfigUtils.isPersistentChatEnabled)(state === null || state === void 0 || (_state$domainStates7 = state.domainStates) === null || _state$domainStates7 === void 0 || (_state$domainStates7 = _state$domainStates7.liveChatConfig) === null || _state$domainStates7 === void 0 || (_state$domainStates7 = _state$domainStates7.LiveWSAndLiveChatEngJoin) === null || _state$domainStates7 === void 0 ? void 0 : _state$domainStates7.msdyn_conversationmode);
@@ -201,14 +275,15 @@ const initStartChat = async (facadeChatSDK, dispatch, setAdapter, state, props,
201
275
  }
202
276
  try {
203
277
  var _window$Microsoft, _state$domainStates8;
204
- // Set custom context params
205
- await setCustomContextParams(state, props);
278
+ // Set custom context params. Returned (not stored module-side) so that concurrent
279
+ // widget instances cannot overwrite each other's custom context.
280
+ const customContextParams = await setCustomContextParams(state, props);
206
281
  const defaultOptionalParams = {
207
282
  sendDefaultInitContext: true,
208
283
  isProactiveChat: !!(params !== null && params !== void 0 && params.isProactiveChat),
209
284
  portalContactId: (_window$Microsoft = window.Microsoft) === null || _window$Microsoft === void 0 || (_window$Microsoft = _window$Microsoft.Dynamic365) === null || _window$Microsoft === void 0 || (_window$Microsoft = _window$Microsoft.Portal) === null || _window$Microsoft === void 0 || (_window$Microsoft = _window$Microsoft.User) === null || _window$Microsoft === void 0 ? void 0 : _window$Microsoft.contactId
210
285
  };
211
- const startChatOptionalParams = Object.assign({}, params, optionalParams, defaultOptionalParams);
286
+ const startChatOptionalParams = Object.assign({}, params, customContextParams, defaultOptionalParams);
212
287
 
213
288
  // MID-AUTH: Add wasAuthenticated flag for reconnect scenarios
214
289
  // Tells FacadeChatSDK whether the previous session was authenticated
@@ -291,14 +366,10 @@ const initStartChat = async (facadeChatSDK, dispatch, setAdapter, state, props,
291
366
  await (0, _updateSessionDataForTelemetry.updateTelemetryData)(facadeChatSDK, dispatch);
292
367
  } catch (ex) {
293
368
  (0, _startChatErrorHandler.handleStartChatError)(dispatch, facadeChatSDK, props, ex, isStartChatSuccessful);
294
- } finally {
295
- optionalParams = {};
296
- widgetInstanceId = "";
297
369
  }
298
370
  };
299
371
 
300
372
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
301
- exports.initStartChat = initStartChat;
302
373
  const createAdapterAndSubscribe = async (facadeChatSDK, dispatch, setAdapter, startTime, props) => {
303
374
  // New adapter creation
304
375
  const newAdapter = await (0, _createAdapter.createAdapter)(facadeChatSDK, props);
@@ -338,19 +409,22 @@ const canConnectToExistingChat = async (props, facadeChatSDK, state, dispatch, s
338
409
  return false;
339
410
  };
340
411
 
341
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
412
+ /**
413
+ * Resolves the custom context to start the chat with.
414
+ *
415
+ * Returns the resolved params instead of mutating module-level state: a page can host several
416
+ * independent widgets, and once per-instance start-chat guards allow them to run concurrently,
417
+ * shared mutable state would let one widget overwrite another widget's custom context.
418
+ */
342
419
  const setCustomContextParams = async (state, props) => {
343
420
  var _state$domainStates9, _persistedState$domai5;
344
421
  if (state !== null && state !== void 0 && (_state$domainStates9 = state.domainStates) !== null && _state$domainStates9 !== void 0 && _state$domainStates9.customContext) {
345
422
  var _state$domainStates0;
346
- optionalParams = Object.assign({}, optionalParams, {
423
+ return {
347
424
  customContext: JSON.parse(JSON.stringify(state === null || state === void 0 || (_state$domainStates0 = state.domainStates) === null || _state$domainStates0 === void 0 ? void 0 : _state$domainStates0.customContext))
348
- });
349
- return;
350
- }
351
- if ((0, _utils.isNullOrEmptyString)(widgetInstanceId)) {
352
- widgetInstanceId = (0, _utils.getWidgetCacheIdfromProps)(props);
425
+ };
353
426
  }
427
+ const widgetInstanceId = props ? (0, _utils.getWidgetCacheIdfromProps)(props) : "";
354
428
  // Add custom context only for unauthenticated chat
355
429
  const persistedState = (0, _utils.getStateFromCache)(widgetInstanceId);
356
430
  const customContextLocal = (persistedState === null || persistedState === void 0 || (_persistedState$domai5 = persistedState.domainStates) === null || _persistedState$domai5 === void 0 ? void 0 : _persistedState$domai5.customContext) ?? (props === null || props === void 0 ? void 0 : props.initialCustomContext);
@@ -359,23 +433,23 @@ const setCustomContextParams = async (state, props) => {
359
433
  Event: _TelemetryConstants.TelemetryEvent.SettingCustomContext,
360
434
  Description: "Setting custom context for unauthenticated chat"
361
435
  });
362
- optionalParams = Object.assign({}, optionalParams, {
436
+ return {
363
437
  customContext: JSON.parse(JSON.stringify(customContextLocal))
364
- });
365
- } else {
366
- const customContextFromParent = await getInitContextParamsForPopout();
367
- if (!(0, _utils.isUndefinedOrEmpty)(customContextFromParent === null || customContextFromParent === void 0 ? void 0 : customContextFromParent.contextVariables)) {
368
- optionalParams = Object.assign({}, optionalParams, {
369
- customContext: JSON.parse(JSON.stringify(customContextFromParent.contextVariables))
370
- });
371
- }
438
+ };
439
+ }
440
+ const customContextFromParent = await getInitContextParamsForPopout();
441
+ if (!(0, _utils.isUndefinedOrEmpty)(customContextFromParent === null || customContextFromParent === void 0 ? void 0 : customContextFromParent.contextVariables)) {
442
+ return {
443
+ customContext: JSON.parse(JSON.stringify(customContextFromParent.contextVariables))
444
+ };
372
445
  }
446
+ return {};
373
447
  };
374
448
  const canStartPopoutChat = async props => {
375
449
  if (props.allowSdkChatSupport === false) {
376
450
  return false;
377
451
  }
378
- popoutWidgetInstanceId = (0, _utils.getWidgetCacheIdfromProps)(props, true);
452
+ const popoutWidgetInstanceId = (0, _utils.getWidgetCacheIdfromProps)(props, true);
379
453
  if (!(0, _utils.isNullOrEmptyString)(popoutWidgetInstanceId)) {
380
454
  var _persistedState$domai6, _persistedState$appSt3;
381
455
  const persistedState = (0, _utils.getStateFromCache)(popoutWidgetInstanceId);
@@ -141,6 +141,8 @@ export let TelemetryEvent = /*#__PURE__*/function (TelemetryEvent) {
141
141
  TelemetryEvent["PrechatSubmitted"] = "PrechatSubmitted";
142
142
  TelemetryEvent["StartChatSDKCall"] = "StartChatCall";
143
143
  TelemetryEvent["StartChatEventReceived"] = "StartChatEventReceived";
144
+ TelemetryEvent["ConcurrentStartChatRejected"] = "ConcurrentStartChatRejected";
145
+ TelemetryEvent["ChatSDKStateCleanUpDisconnectFailed"] = "ChatSDKStateCleanUpDisconnectFailed";
144
146
  TelemetryEvent["EndChatSDKCall"] = "EndChatSDKCall";
145
147
  TelemetryEvent["PrepareEndChat"] = "PrepareEndChat";
146
148
  TelemetryEvent["EndChatEventReceived"] = "EndChatEventReceived";
@@ -413,6 +415,8 @@ export let TelemetryConstants = /*#__PURE__*/function () {
413
415
  case TelemetryEvent.SecureEventBusDispatchError:
414
416
  case TelemetryEvent.AgentJoinedConversation:
415
417
  case TelemetryEvent.BrowserTabHidden:
418
+ case TelemetryEvent.ConcurrentStartChatRejected:
419
+ case TelemetryEvent.ChatSDKStateCleanUpDisconnectFailed:
416
420
  case TelemetryEvent.RecoveryEligibilityEvaluated:
417
421
  return ScenarioType.ACTIONS;
418
422
  case TelemetryEvent.StartChatSDKCall:
@@ -50,37 +50,54 @@ export const ChatButtonStateful = props => {
50
50
  const ref = useRef(() => {
51
51
  return;
52
52
  });
53
+ // Optimistic guard against rapid double-clicks. `conversationState` only flips to
54
+ // `Loading` after React re-renders, so the button stays visible and clickable for the
55
+ // whole duration of the first click's async work. Without this ref two concurrent
56
+ // startChat() calls can create two conversations on the backend.
57
+ const startChatInProgressRef = useRef(false);
53
58
  ref.current = async () => {
54
- TelemetryHelper.logActionEventToAllTelemetry(LogLevel.INFO, {
55
- Event: TelemetryEvent.LCWChatButtonClicked,
56
- Description: "Chat button click action started",
57
- CustomProperties: {
58
- ConversationStage: ConversationStage.Initialization
59
- }
60
- });
61
- if (state.appStates.isMinimized) {
62
- dispatch({
63
- type: LiveChatWidgetActionType.SET_MINIMIZED,
64
- payload: false
59
+ if (startChatInProgressRef.current) {
60
+ TelemetryHelper.logActionEvent(LogLevel.WARN, {
61
+ Event: TelemetryEvent.ConcurrentStartChatRejected,
62
+ Description: "Chat button click ignored, a start chat operation is already in progress."
65
63
  });
66
- dispatch({
67
- type: LiveChatWidgetActionType.SET_UNREAD_MESSAGE_COUNT,
68
- payload: 0
64
+ return;
65
+ }
66
+ startChatInProgressRef.current = true;
67
+ try {
68
+ TelemetryHelper.logActionEventToAllTelemetry(LogLevel.INFO, {
69
+ Event: TelemetryEvent.LCWChatButtonClicked,
70
+ Description: "Chat button click action started",
71
+ CustomProperties: {
72
+ ConversationStage: ConversationStage.Initialization
73
+ }
69
74
  });
70
- // If chat is minimized and then unminimized, start a chat if convesation state is closed.
71
- if (state.appStates.conversationState === ConversationState.Closed) {
75
+ if (state.appStates.isMinimized) {
76
+ dispatch({
77
+ type: LiveChatWidgetActionType.SET_MINIMIZED,
78
+ payload: false
79
+ });
80
+ dispatch({
81
+ type: LiveChatWidgetActionType.SET_UNREAD_MESSAGE_COUNT,
82
+ payload: 0
83
+ });
84
+ // If chat is minimized and then unminimized, start a chat if convesation state is closed.
85
+ if (state.appStates.conversationState === ConversationState.Closed) {
86
+ await startChat();
87
+ }
88
+ } else {
72
89
  await startChat();
73
90
  }
74
- } else {
75
- await startChat();
91
+ TelemetryHelper.logActionEventToAllTelemetry(LogLevel.INFO, {
92
+ Event: TelemetryEvent.LCWChatButtonActionCompleted,
93
+ Description: "Chat button action completed",
94
+ CustomProperties: {
95
+ ConversationStage: ConversationStage.Initialization
96
+ }
97
+ });
98
+ } finally {
99
+ startChatInProgressRef.current = false;
76
100
  }
77
- TelemetryHelper.logActionEventToAllTelemetry(LogLevel.INFO, {
78
- Event: TelemetryEvent.LCWChatButtonActionCompleted,
79
- Description: "Chat button action completed",
80
- CustomProperties: {
81
- ConversationStage: ConversationStage.Initialization
82
- }
83
- });
84
101
  };
85
102
  const outOfOfficeStyleProps = Object.assign({}, defaultOutOfOfficeChatButtonStyleProps, outOfOfficeButtonProps === null || outOfOfficeButtonProps === void 0 ? void 0 : outOfOfficeButtonProps.styleProps);
86
103
  const controlProps = {
@@ -349,14 +349,53 @@ export const closeChatStateCleanUp = dispatch => {
349
349
  });
350
350
  };
351
351
 
352
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
+ const logCleanUpDisconnectFailure = error => {
354
+ TelemetryHelper.logActionEvent(LogLevel.WARN, {
355
+ Event: TelemetryEvent.ChatSDKStateCleanUpDisconnectFailed,
356
+ Description: "Failed to disconnect the previous conversation during chat SDK state clean up.",
357
+ ExceptionDetails: {
358
+ exception: (error === null || error === void 0 ? void 0 : error.message) ?? error
359
+ }
360
+ });
361
+ };
362
+
363
+ // Releases the previous session's conversation. Mirrors the cleanup OmnichannelChatSDK.endChat()
364
+ // runs in its finally block (disconnect, then drop the reference). Without it, a conversation
365
+ // left over from a failed or orphaned start chat, or from a chat ended in another tab (which
366
+ // skips the SDK endChat), keeps its messaging session open and leaks into the next chat.
367
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
368
+ const disconnectConversation = chatSDK => {
369
+ const conversation = chatSDK.conversation;
370
+ if (!conversation || typeof conversation.disconnect !== "function") {
371
+ return;
372
+ }
373
+ try {
374
+ // ACS conversations disconnect asynchronously; legacy IC3 conversations may not return a promise.
375
+ const result = conversation.disconnect();
376
+ if (result && typeof result.catch === "function") {
377
+ result.catch(logCleanUpDisconnectFailure);
378
+ }
379
+ } catch (error) {
380
+ logCleanUpDisconnectFailure(error);
381
+ }
382
+ };
383
+
352
384
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
385
  export const chatSDKStateCleanUp = chatSDK => {
386
+ if (!chatSDK) {
387
+ return;
388
+ }
389
+
354
390
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
355
391
  chatSDK.requestId = uuidv4();
356
392
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
357
393
  chatSDK.chatToken = {};
358
394
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
359
395
  chatSDK.reconnectId = null;
396
+ disconnectConversation(chatSDK);
397
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
398
+ chatSDK.conversation = null;
360
399
  };
361
400
 
362
401
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -18,19 +18,86 @@ import { isMidAuthEnabled } from "./authHelper";
18
18
  import { setPostChatContextAndLoadSurvey } from "./setPostChatContextAndLoadSurvey";
19
19
  import { shouldSetPreChatIfPersistentChat } from "./persistentChatHelper";
20
20
  import { updateTelemetryData } from "./updateSessionDataForTelemetry";
21
+ /**
22
+ * Per widget instance mutexes for the two chat creation entry points.
23
+ *
24
+ * Every guard in the prepareStartChat -> setPreChatAndInitiateChat -> initStartChat chain
25
+ * checks `conversationState === Closed`, but React state is updated asynchronously. A second
26
+ * invocation fired within the same render cycle (for example a rapid double click on the chat
27
+ * button, or a chat button click racing a BroadcastEvent.StartChat) therefore still observes
28
+ * the stale `Closed` state, reaches `facadeChatSDK.startChat()` and creates a second
29
+ * conversation on the backend which is then orphaned and never ended.
30
+ *
31
+ * The locks are keyed by FacadeChatSDK instance rather than being module wide, because a host
32
+ * page can mount several independent widgets, each with its own widgetInstanceId and its own
33
+ * FacadeChatSDK (created once per widget in LiveChatWidget.tsx). A module wide lock would make
34
+ * widget A's start chat silently drop widget B's legitimate simultaneous start chat.
35
+ *
36
+ * The two maps are separate because prepareStartChat calls initStartChat; sharing a single lock
37
+ * would make the nested call see its own parent as in flight and drop it. prepareStartChat is
38
+ * additionally blocked by an in flight initStartChat (pre-chat submit, reconnect, or the
39
+ * hideStartChatButton bootstrap call initStartChat directly). Otherwise a later prepareStartChat
40
+ * would dispatch Loading and maximize the widget, and only then have its nested initStartChat
41
+ * dropped.
42
+ */
43
+ const prepareStartChatGuards = new WeakMap();
44
+ const initStartChatGuards = new WeakMap();
21
45
 
22
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
- let optionalParams = {};
24
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
- let widgetInstanceId;
26
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
- let popoutWidgetInstanceId;
46
+ // Fallback owner used only when no FacadeChatSDK instance is available to key on, so the guard
47
+ // degrades to the module wide behaviour instead of disappearing altogether.
48
+ const unkeyedGuardOwner = {};
49
+ const getGuard = (guards, facadeChatSDK) => {
50
+ const owner = facadeChatSDK ?? unkeyedGuardOwner;
51
+ let guard = guards.get(owner);
52
+ if (!guard) {
53
+ guard = {
54
+ inFlight: false
55
+ };
56
+ guards.set(owner, guard);
57
+ }
58
+ return guard;
59
+ };
60
+
61
+ /**
62
+ * Runs `operation` only if no other invocation for the same widget instance is already in
63
+ * flight. Concurrent invocations are dropped (and reported through telemetry) rather than
64
+ * awaiting the in-flight promise, so a future re-entrant call can never deadlock on itself.
65
+ * No caller acts on the resolved value, so dropping is safe.
66
+ *
67
+ * `blockedBy` lets an operation also yield to a different, already running operation for the
68
+ * same widget instance without claiming that operation's lock.
69
+ */
70
+ const runExclusively = async (guards, facadeChatSDK, description, operation, props, blockedBy) => {
71
+ const guard = getGuard(guards, facadeChatSDK);
72
+ const blockingGuard = blockedBy ? getGuard(blockedBy, facadeChatSDK) : undefined;
73
+ if (guard.inFlight || blockingGuard !== null && blockingGuard !== void 0 && blockingGuard.inFlight) {
74
+ TelemetryHelper.logActionEvent(LogLevel.WARN, {
75
+ Event: TelemetryEvent.ConcurrentStartChatRejected,
76
+ Description: description,
77
+ CustomProperties: {
78
+ WidgetCacheId: props ? getWidgetCacheIdfromProps(props) : undefined
79
+ }
80
+ });
81
+ return;
82
+ }
83
+
84
+ // Claim the lock before invoking the operation, so a re-entrant call made from the
85
+ // operation's synchronous preamble (before its first await) cannot slip past the check above.
86
+ guard.inFlight = true;
87
+ try {
88
+ await operation();
89
+ } finally {
90
+ guard.inFlight = false;
91
+ }
92
+ };
28
93
 
29
94
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
95
  const prepareStartChat = async (props, facadeChatSDK, state, dispatch, setAdapter) => {
31
- optionalParams = {}; //Resetting to ensure no stale values
32
- widgetInstanceId = getWidgetCacheIdfromProps(props);
96
+ return runExclusively(prepareStartChatGuards, facadeChatSDK, "Concurrent prepareStartChat call ignored, a start chat operation is already in progress for this widget instance.", () => prepareStartChatInternal(props, facadeChatSDK, state, dispatch, setAdapter), props, initStartChatGuards);
97
+ };
33
98
 
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ const prepareStartChatInternal = async (props, facadeChatSDK, state, dispatch, setAdapter) => {
34
101
  // reconnect > chat from cache
35
102
  if (isReconnectEnabled(props.chatConfig) === true && !isPersistentEnabled(props.chatConfig)) {
36
103
  const shouldStartChatNormally = await handleChatReconnect(facadeChatSDK, props, dispatch, setAdapter, initStartChat, state);
@@ -156,6 +223,11 @@ const setPreChatAndInitiateChat = async (facadeChatSDK, dispatch, setAdapter, is
156
223
 
157
224
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
225
  const initStartChat = async (facadeChatSDK, dispatch, setAdapter, state, props, params, persistedState) => {
226
+ return runExclusively(initStartChatGuards, facadeChatSDK, "Concurrent initStartChat call ignored, a start chat operation is already in progress for this widget instance.", () => initStartChatInternal(facadeChatSDK, dispatch, setAdapter, state, props, params, persistedState), props);
227
+ };
228
+
229
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
+ const initStartChatInternal = async (facadeChatSDK, dispatch, setAdapter, state, props, params, persistedState) => {
159
231
  var _state$domainStates7;
160
232
  let isStartChatSuccessful = false;
161
233
  const persistentChatEnabled = isPersistentChatEnabled(state === null || state === void 0 || (_state$domainStates7 = state.domainStates) === null || _state$domainStates7 === void 0 || (_state$domainStates7 = _state$domainStates7.liveChatConfig) === null || _state$domainStates7 === void 0 || (_state$domainStates7 = _state$domainStates7.LiveWSAndLiveChatEngJoin) === null || _state$domainStates7 === void 0 ? void 0 : _state$domainStates7.msdyn_conversationmode);
@@ -194,14 +266,15 @@ const initStartChat = async (facadeChatSDK, dispatch, setAdapter, state, props,
194
266
  }
195
267
  try {
196
268
  var _window$Microsoft, _state$domainStates8;
197
- // Set custom context params
198
- await setCustomContextParams(state, props);
269
+ // Set custom context params. Returned (not stored module-side) so that concurrent
270
+ // widget instances cannot overwrite each other's custom context.
271
+ const customContextParams = await setCustomContextParams(state, props);
199
272
  const defaultOptionalParams = {
200
273
  sendDefaultInitContext: true,
201
274
  isProactiveChat: !!(params !== null && params !== void 0 && params.isProactiveChat),
202
275
  portalContactId: (_window$Microsoft = window.Microsoft) === null || _window$Microsoft === void 0 || (_window$Microsoft = _window$Microsoft.Dynamic365) === null || _window$Microsoft === void 0 || (_window$Microsoft = _window$Microsoft.Portal) === null || _window$Microsoft === void 0 || (_window$Microsoft = _window$Microsoft.User) === null || _window$Microsoft === void 0 ? void 0 : _window$Microsoft.contactId
203
276
  };
204
- const startChatOptionalParams = Object.assign({}, params, optionalParams, defaultOptionalParams);
277
+ const startChatOptionalParams = Object.assign({}, params, customContextParams, defaultOptionalParams);
205
278
 
206
279
  // MID-AUTH: Add wasAuthenticated flag for reconnect scenarios
207
280
  // Tells FacadeChatSDK whether the previous session was authenticated
@@ -284,9 +357,6 @@ const initStartChat = async (facadeChatSDK, dispatch, setAdapter, state, props,
284
357
  await updateTelemetryData(facadeChatSDK, dispatch);
285
358
  } catch (ex) {
286
359
  handleStartChatError(dispatch, facadeChatSDK, props, ex, isStartChatSuccessful);
287
- } finally {
288
- optionalParams = {};
289
- widgetInstanceId = "";
290
360
  }
291
361
  };
292
362
 
@@ -330,19 +400,22 @@ const canConnectToExistingChat = async (props, facadeChatSDK, state, dispatch, s
330
400
  return false;
331
401
  };
332
402
 
333
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
403
+ /**
404
+ * Resolves the custom context to start the chat with.
405
+ *
406
+ * Returns the resolved params instead of mutating module-level state: a page can host several
407
+ * independent widgets, and once per-instance start-chat guards allow them to run concurrently,
408
+ * shared mutable state would let one widget overwrite another widget's custom context.
409
+ */
334
410
  const setCustomContextParams = async (state, props) => {
335
411
  var _state$domainStates9, _persistedState$domai5;
336
412
  if (state !== null && state !== void 0 && (_state$domainStates9 = state.domainStates) !== null && _state$domainStates9 !== void 0 && _state$domainStates9.customContext) {
337
413
  var _state$domainStates0;
338
- optionalParams = Object.assign({}, optionalParams, {
414
+ return {
339
415
  customContext: JSON.parse(JSON.stringify(state === null || state === void 0 || (_state$domainStates0 = state.domainStates) === null || _state$domainStates0 === void 0 ? void 0 : _state$domainStates0.customContext))
340
- });
341
- return;
342
- }
343
- if (isNullOrEmptyString(widgetInstanceId)) {
344
- widgetInstanceId = getWidgetCacheIdfromProps(props);
416
+ };
345
417
  }
418
+ const widgetInstanceId = props ? getWidgetCacheIdfromProps(props) : "";
346
419
  // Add custom context only for unauthenticated chat
347
420
  const persistedState = getStateFromCache(widgetInstanceId);
348
421
  const customContextLocal = (persistedState === null || persistedState === void 0 || (_persistedState$domai5 = persistedState.domainStates) === null || _persistedState$domai5 === void 0 ? void 0 : _persistedState$domai5.customContext) ?? (props === null || props === void 0 ? void 0 : props.initialCustomContext);
@@ -351,23 +424,23 @@ const setCustomContextParams = async (state, props) => {
351
424
  Event: TelemetryEvent.SettingCustomContext,
352
425
  Description: "Setting custom context for unauthenticated chat"
353
426
  });
354
- optionalParams = Object.assign({}, optionalParams, {
427
+ return {
355
428
  customContext: JSON.parse(JSON.stringify(customContextLocal))
356
- });
357
- } else {
358
- const customContextFromParent = await getInitContextParamsForPopout();
359
- if (!isUndefinedOrEmpty(customContextFromParent === null || customContextFromParent === void 0 ? void 0 : customContextFromParent.contextVariables)) {
360
- optionalParams = Object.assign({}, optionalParams, {
361
- customContext: JSON.parse(JSON.stringify(customContextFromParent.contextVariables))
362
- });
363
- }
429
+ };
430
+ }
431
+ const customContextFromParent = await getInitContextParamsForPopout();
432
+ if (!isUndefinedOrEmpty(customContextFromParent === null || customContextFromParent === void 0 ? void 0 : customContextFromParent.contextVariables)) {
433
+ return {
434
+ customContext: JSON.parse(JSON.stringify(customContextFromParent.contextVariables))
435
+ };
364
436
  }
437
+ return {};
365
438
  };
366
439
  const canStartPopoutChat = async props => {
367
440
  if (props.allowSdkChatSupport === false) {
368
441
  return false;
369
442
  }
370
- popoutWidgetInstanceId = getWidgetCacheIdfromProps(props, true);
443
+ const popoutWidgetInstanceId = getWidgetCacheIdfromProps(props, true);
371
444
  if (!isNullOrEmptyString(popoutWidgetInstanceId)) {
372
445
  var _persistedState$domai6, _persistedState$appSt3;
373
446
  const persistedState = getStateFromCache(popoutWidgetInstanceId);
@@ -127,6 +127,8 @@ export declare enum TelemetryEvent {
127
127
  PrechatSubmitted = "PrechatSubmitted",
128
128
  StartChatSDKCall = "StartChatCall",
129
129
  StartChatEventReceived = "StartChatEventReceived",
130
+ ConcurrentStartChatRejected = "ConcurrentStartChatRejected",
131
+ ChatSDKStateCleanUpDisconnectFailed = "ChatSDKStateCleanUpDisconnectFailed",
130
132
  EndChatSDKCall = "EndChatSDKCall",
131
133
  PrepareEndChat = "PrepareEndChat",
132
134
  EndChatEventReceived = "EndChatEventReceived",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microsoft/omnichannel-chat-widget",
3
- "version": "2.0.1-main.84e2c02",
3
+ "version": "2.0.1-main.c997354",
4
4
  "description": "Microsoft Omnichannel Chat Widget",
5
5
  "main": "lib/cjs/index.js",
6
6
  "types": "lib/types/index.d.ts",