diff --git a/packages/twenty-front/src/modules/support/hooks/useInstantiateSupportChat.ts b/packages/twenty-front/src/modules/support/hooks/useInstantiateSupportChat.ts index a707bb4bec..b444842a44 100644 --- a/packages/twenty-front/src/modules/support/hooks/useInstantiateSupportChat.ts +++ b/packages/twenty-front/src/modules/support/hooks/useInstantiateSupportChat.ts @@ -6,6 +6,14 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useCallback, useEffect, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { type User, type WorkspaceMember } from '~/generated-metadata/graphql'; +import { scheduleIdleCallback } from '~/utils/scheduleIdleCallback'; + +// Front chat is non-critical UI, so we load its ~2 s bundle during an idle +// period rather than letting it compete with metadata loading and first +// render during boot. The timeout caps the wait so the launcher (and any +// unread-reply badge) still appears promptly, and it doubles as the plain +// delay on browsers without requestIdleCallback (Safari/iOS). +const FRONT_CHAT_IDLE_LOAD_TIMEOUT_MS = 2000; const insertScript = ({ src, @@ -77,13 +85,16 @@ export const useInstantiateSupportChat = () => { isDefined(currentWorkspaceMember) && !isFrontChatLoaded ) { - setTimeout(() => { - configureFront( - supportChat.supportFrontChatId as string, - currentUser, - currentWorkspaceMember, - ); - }, 500); + return scheduleIdleCallback( + () => { + configureFront( + supportChat.supportFrontChatId as string, + currentUser, + currentWorkspaceMember, + ); + }, + { timeout: FRONT_CHAT_IDLE_LOAD_TIMEOUT_MS }, + ); } }, [ configureFront, diff --git a/packages/twenty-front/src/utils/__tests__/scheduleIdleCallback.test.ts b/packages/twenty-front/src/utils/__tests__/scheduleIdleCallback.test.ts new file mode 100644 index 0000000000..eac80678b5 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/scheduleIdleCallback.test.ts @@ -0,0 +1,86 @@ +import { scheduleIdleCallback } from '~/utils/scheduleIdleCallback'; + +type RequestIdleCallback = typeof window.requestIdleCallback; +type CancelIdleCallback = typeof window.cancelIdleCallback; + +const setIdleCallbackApi = ( + requestIdleCallback: RequestIdleCallback | undefined, + cancelIdleCallback: CancelIdleCallback | undefined, +) => { + Object.defineProperty(window, 'requestIdleCallback', { + configurable: true, + writable: true, + value: requestIdleCallback, + }); + Object.defineProperty(window, 'cancelIdleCallback', { + configurable: true, + writable: true, + value: cancelIdleCallback, + }); +}; + +describe('scheduleIdleCallback', () => { + const originalRequestIdleCallback = window.requestIdleCallback; + const originalCancelIdleCallback = window.cancelIdleCallback; + + afterEach(() => { + setIdleCallbackApi(originalRequestIdleCallback, originalCancelIdleCallback); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + describe('when requestIdleCallback is available', () => { + it('schedules the callback through requestIdleCallback with the timeout', () => { + const requestIdleCallbackMock = jest.fn(() => 42); + setIdleCallbackApi(requestIdleCallbackMock, jest.fn()); + + const callback = jest.fn(); + scheduleIdleCallback(callback, { timeout: 2000 }); + + expect(requestIdleCallbackMock).toHaveBeenCalledWith(callback, { + timeout: 2000, + }); + }); + + it('cancels the scheduled callback through cancelIdleCallback', () => { + const cancelIdleCallbackMock = jest.fn(); + setIdleCallbackApi( + jest.fn(() => 42), + cancelIdleCallbackMock, + ); + + const cancel = scheduleIdleCallback(jest.fn(), { timeout: 2000 }); + cancel(); + + expect(cancelIdleCallbackMock).toHaveBeenCalledWith(42); + }); + }); + + describe('when requestIdleCallback is not available (e.g. Safari/iOS)', () => { + beforeEach(() => { + jest.useFakeTimers(); + setIdleCallbackApi(undefined, undefined); + }); + + it('falls back to running the callback after the timeout', () => { + const callback = jest.fn(); + scheduleIdleCallback(callback, { timeout: 2000 }); + + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(2000); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('cancels the fallback timeout before it runs', () => { + const callback = jest.fn(); + const cancel = scheduleIdleCallback(callback, { timeout: 2000 }); + + cancel(); + jest.advanceTimersByTime(2000); + + expect(callback).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-front/src/utils/scheduleIdleCallback.ts b/packages/twenty-front/src/utils/scheduleIdleCallback.ts new file mode 100644 index 0000000000..93a9b924a0 --- /dev/null +++ b/packages/twenty-front/src/utils/scheduleIdleCallback.ts @@ -0,0 +1,19 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const scheduleIdleCallback = ( + callback: () => void, + { timeout }: { timeout: number }, +): (() => void) => { + const requestIdleCallbackFn: typeof window.requestIdleCallback | undefined = + window.requestIdleCallback; + + if (isDefined(requestIdleCallbackFn)) { + const handle = requestIdleCallbackFn(callback, { timeout }); + + return () => window.cancelIdleCallback(handle); + } + + const handle = window.setTimeout(callback, timeout); + + return () => window.clearTimeout(handle); +};