fix: Prevent duplicate chat creation in the web widget during latency (#10745)

This commit is contained in:
Sivin Varghese
2025-01-23 12:53:28 +05:30
committed by GitHub
parent be8205657e
commit d3d39a81d6
10 changed files with 90 additions and 8 deletions

View File

@@ -24,7 +24,8 @@ export default {
}, },
computed: { computed: {
buttonClassName() { buttonClassName() {
let className = 'text-white py-3 px-4 rounded shadow-sm leading-4'; let className =
'text-white py-3 px-4 rounded shadow-sm leading-4 cursor-pointer disabled:opacity-50';
if (this.type === 'clear') { if (this.type === 'clear') {
className = 'flex mx-auto mt-4 text-xs leading-3 w-auto text-black-600'; className = 'flex mx-auto mt-4 text-xs leading-3 w-auto text-black-600';
} }

View File

@@ -52,9 +52,13 @@ export default {
...mapGetters({ ...mapGetters({
widgetColor: 'appConfig/getWidgetColor', widgetColor: 'appConfig/getWidgetColor',
isCreating: 'conversation/getIsCreating', isCreating: 'conversation/getIsCreating',
isConversationRouting: 'appConfig/getIsUpdatingRoute',
activeCampaign: 'campaign/getActiveCampaign', activeCampaign: 'campaign/getActiveCampaign',
currentUser: 'contacts/getCurrentUser', currentUser: 'contacts/getCurrentUser',
}), }),
isCreatingConversation() {
return this.isCreating || this.isConversationRouting;
},
textColor() { textColor() {
return getContrastingTextColor(this.widgetColor); return getContrastingTextColor(this.widgetColor);
}, },
@@ -337,9 +341,9 @@ export default {
block block
:bg-color="widgetColor" :bg-color="widgetColor"
:text-color="textColor" :text-color="textColor"
:disabled="isCreating" :disabled="isCreatingConversation"
> >
<Spinner v-if="isCreating" class="p-0" /> <Spinner v-if="isCreatingConversation" class="p-0" />
{{ $t('START_CONVERSATION') }} {{ $t('START_CONVERSATION') }}
</CustomButton> </CustomButton>
</FormKit> </FormKit>

View File

@@ -1,7 +1,8 @@
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from 'vue-router';
import ViewWithHeader from './components/layouts/ViewWithHeader.vue'; import ViewWithHeader from './components/layouts/ViewWithHeader.vue';
import store from './store';
export default createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: [ routes: [
{ {
@@ -42,3 +43,35 @@ export default createRouter({
}, },
], ],
}); });
/**
* Navigation Guards to Handle Route Transitions
*
* Purpose:
* Prevents duplicate form submissions and API calls during route transitions,
* especially important in high-latency scenarios.
*
* Flow:
* 1. beforeEach: Sets isUpdatingRoute to true at start of navigation
* 2. Component buttons/actions check this flag to prevent duplicate actions
* 3. afterEach: Resets the flag once navigation is complete
*
* Implementation note:
* Handling it globally, so that we can use it across all components
* to ensure consistent UI behavior during all route transitions.
*
* @see https://github.com/chatwoot/chatwoot/issues/10736
*/
router.beforeEach(async (to, from, next) => {
// Prevent any user interactions during route transition
await store.dispatch('appConfig/setRouteTransitionState', true);
next();
});
router.afterEach(() => {
// Re-enable user interactions after navigation is complete
store.dispatch('appConfig/setRouteTransitionState', false);
});
export default router;

View File

@@ -5,6 +5,7 @@ import {
SET_WIDGET_APP_CONFIG, SET_WIDGET_APP_CONFIG,
SET_WIDGET_COLOR, SET_WIDGET_COLOR,
TOGGLE_WIDGET_OPEN, TOGGLE_WIDGET_OPEN,
SET_ROUTE_UPDATE_STATE,
} from '../types'; } from '../types';
const state = { const state = {
@@ -19,6 +20,7 @@ const state = {
widgetColor: '', widgetColor: '',
widgetStyle: 'standard', widgetStyle: 'standard',
darkMode: 'light', darkMode: 'light',
isUpdatingRoute: false,
}; };
export const getters = { export const getters = {
@@ -31,6 +33,7 @@ export const getters = {
isWidgetStyleFlat: $state => $state.widgetStyle === 'flat', isWidgetStyleFlat: $state => $state.widgetStyle === 'flat',
darkMode: $state => $state.darkMode, darkMode: $state => $state.darkMode,
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog, getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
getIsUpdatingRoute: _state => _state.isUpdatingRoute,
}; };
export const actions = { export const actions = {
@@ -69,6 +72,13 @@ export const actions = {
setBubbleVisibility({ commit }, hideMessageBubble) { setBubbleVisibility({ commit }, hideMessageBubble) {
commit(SET_BUBBLE_VISIBILITY, hideMessageBubble); commit(SET_BUBBLE_VISIBILITY, hideMessageBubble);
}, },
setRouteTransitionState: async ({ commit }, status) => {
// Handles the routing state during navigation to different screen
// Called before the navigation starts and after navigation completes
// Handling this state in app/javascript/widget/router.js
// See issue: https://github.com/chatwoot/chatwoot/issues/10736
commit(SET_ROUTE_UPDATE_STATE, status);
},
}; };
export const mutations = { export const mutations = {
@@ -96,6 +106,9 @@ export const mutations = {
[SET_COLOR_SCHEME]($state, darkMode) { [SET_COLOR_SCHEME]($state, darkMode) {
$state.darkMode = darkMode; $state.darkMode = darkMode;
}, },
[SET_ROUTE_UPDATE_STATE]($state, status) {
$state.isUpdatingRoute = status;
},
}; };
export default { export default {

View File

@@ -31,4 +31,11 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([['SET_COLOR_SCHEME', 'dark']]); expect(commit.mock.calls).toEqual([['SET_COLOR_SCHEME', 'dark']]);
}); });
}); });
describe('#setRouteTransitionState', () => {
it('creates actions properly', () => {
actions.setRouteTransitionState({ commit }, false);
expect(commit.mock.calls).toEqual([['SET_ROUTE_UPDATE_STATE', false]]);
});
});
}); });

View File

@@ -19,4 +19,10 @@ describe('#getters', () => {
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true); expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
}); });
}); });
describe('#getIsUpdatingRoute', () => {
it('returns correct value', () => {
const state = { isUpdatingRoute: true };
expect(getters.getIsUpdatingRoute(state)).toEqual(true);
});
});
}); });

View File

@@ -32,4 +32,12 @@ describe('#mutations', () => {
expect(state.darkMode).toEqual('dark'); expect(state.darkMode).toEqual('dark');
}); });
}); });
describe('#SET_ROUTE_UPDATE_STATE', () => {
it('sets dark mode properly', () => {
const state = { isUpdatingRoute: false };
mutations.SET_ROUTE_UPDATE_STATE(state, true);
expect(state.isUpdatingRoute).toEqual(true);
});
});
}); });

View File

@@ -17,6 +17,7 @@ describe('#actions', () => {
messages: [{ id: 1, content: 'This is a test message' }], messages: [{ id: 1, content: 'This is a test message' }],
}, },
}); });
let windowSpy = vi.spyOn(window, 'window', 'get'); let windowSpy = vi.spyOn(window, 'window', 'get');
windowSpy.mockImplementation(() => ({ windowSpy.mockImplementation(() => ({
WOOT_WIDGET: { WOOT_WIDGET: {

View File

@@ -7,3 +7,4 @@ export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';
export const TOGGLE_WIDGET_OPEN = 'TOGGLE_WIDGET_OPEN'; export const TOGGLE_WIDGET_OPEN = 'TOGGLE_WIDGET_OPEN';
export const SET_REFERRER_HOST = 'SET_REFERRER_HOST'; export const SET_REFERRER_HOST = 'SET_REFERRER_HOST';
export const SET_BUBBLE_VISIBILITY = 'SET_BUBBLE_VISIBILITY'; export const SET_BUBBLE_VISIBILITY = 'SET_BUBBLE_VISIBILITY';
export const SET_ROUTE_UPDATE_STATE = 'SET_ROUTE_UPDATE_STATE';

View File

@@ -12,12 +12,20 @@ export default {
}, },
mixins: [configMixin, routerMixin], mixins: [configMixin, routerMixin],
mounted() { mounted() {
emitter.on(ON_CONVERSATION_CREATED, () => { // Register event listener for conversation creation
// Redirect to messages page after conversation is created emitter.on(ON_CONVERSATION_CREATED, this.handleConversationCreated);
this.replaceRoute('messages'); },
}); beforeUnmount() {
emitter.off(ON_CONVERSATION_CREATED, this.handleConversationCreated);
}, },
methods: { methods: {
handleConversationCreated() {
// Redirect to messages page after conversation is created
this.replaceRoute('messages');
// Only after successful navigation, reset the isUpdatingRoute UIflag in app/javascript/widget/router.js
// See issue: https://github.com/chatwoot/chatwoot/issues/10736
},
onSubmit({ onSubmit({
fullName, fullName,
emailAddress, emailAddress,