11import { useIntl } from '@edx/frontend-platform/i18n' ;
22import { useQuery , useQueryClient } from '@tanstack/react-query' ;
3- import { useContext , useEffect , useState } from 'react' ;
3+ import { useCallback , useContext , useEffect } from 'react' ;
44
55import { getClipboard , updateClipboard } from '../../data/api' ;
66import {
@@ -11,6 +11,14 @@ import {
1111import { ToastContext } from '../../toast-context' ;
1212import messages from './messages' ;
1313
14+ // Global, shared broadcast channel for the clipboard. Disabled by default in test environment where it's not defined.
15+ let clipboardBroadcastChannel = (
16+ typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel ( STUDIO_CLIPBOARD_CHANNEL ) : null
17+ ) ;
18+ /** To allow mocking the broadcast channel for testing */
19+ // eslint-disable-next-line
20+ export const _testingOverrideBroadcastChannel = ( x : BroadcastChannel ) => { clipboardBroadcastChannel = x ; } ;
21+
1422/**
1523 * Custom React hook for managing clipboard functionality.
1624 *
@@ -23,7 +31,6 @@ import messages from './messages';
2331 */
2432const useClipboard = ( canEdit : boolean = true ) => {
2533 const intl = useIntl ( ) ;
26- const [ clipboardBroadcastChannel ] = useState ( ( ) => new BroadcastChannel ( STUDIO_CLIPBOARD_CHANNEL ) ) ;
2734 const { data : clipboardData } = useQuery ( {
2835 queryKey : [ 'clipboard' ] ,
2936 queryFn : getClipboard ,
@@ -33,37 +40,48 @@ const useClipboard = (canEdit: boolean = true) => {
3340
3441 const queryClient = useQueryClient ( ) ;
3542
36- const copyToClipboard = async ( usageKey : string ) => {
43+ const copyToClipboard = useCallback ( async ( usageKey : string ) => {
3744 // This code is synchronous for now, but it could be made asynchronous in the future.
3845 // In that case, the `done` message should be shown after the asynchronous operation completes.
3946 showToast ( intl . formatMessage ( messages . copying ) ) ;
47+ let newData ;
4048 try {
41- const newData = await updateClipboard ( usageKey ) ;
42- clipboardBroadcastChannel . postMessage ( newData ) ;
49+ newData = await updateClipboard ( usageKey ) ;
4350 queryClient . setQueryData ( [ 'clipboard' ] , newData ) ;
44- showToast ( intl . formatMessage ( messages . done ) ) ;
4551 } catch ( error ) {
4652 showToast ( intl . formatMessage ( messages . error ) ) ;
53+ return ;
4754 }
48- } ;
55+ // Update the clipboard state across all other open browser tabs too:
56+ try {
57+ clipboardBroadcastChannel ?. postMessage ( newData ) ;
58+ } catch ( error ) {
59+ // Log the error but no need to show it to the user.
60+ // istanbul ignore next
61+ // eslint-disable-next-line no-console
62+ console . error ( 'Unable to sync clipboard state across other open tabs:' , error ) ;
63+ }
64+ showToast ( intl . formatMessage ( messages . done ) ) ;
65+ } , [ showToast , intl , queryClient ] ) ;
66+
67+ const handleBroadcastMessage = useCallback ( ( event : MessageEvent ) => {
68+ // Note: if this useClipboard() hook is used many times on one page,
69+ // this will result in many separate calls to setQueryData() whenever
70+ // the clipboard contents change, but that is fine and shouldn't actually
71+ // cause any issues. If it did, we could refactor this into a
72+ // <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
73+ // rather than having a separate channel per useClipboard hook.
74+ queryClient . setQueryData ( [ 'clipboard' ] , event . data ) ;
75+ } , [ queryClient ] ) ;
4976
5077 useEffect ( ( ) => {
5178 // Handle messages from the broadcast channel
52- clipboardBroadcastChannel . onmessage = ( event ) => {
53- // Note: if this useClipboard() hook is used many times on one page,
54- // this will result in many separate calls to setQueryData() whenever
55- // the clipboard contents change, but that is fine and shouldn't actually
56- // cause any issues. If it did, we could refactor this into a
57- // <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
58- // rather than having a separate channel per useClipboard hook.
59- queryClient . setQueryData ( [ 'clipboard' ] , event . data ) ;
60- } ;
61-
79+ clipboardBroadcastChannel ?. addEventListener ( 'message' , handleBroadcastMessage ) ;
6280 // Cleanup function for the BroadcastChannel when the hook is unmounted
6381 return ( ) => {
64- clipboardBroadcastChannel . close ( ) ;
82+ clipboardBroadcastChannel ?. removeEventListener ( 'message' , handleBroadcastMessage ) ;
6583 } ;
66- } , [ clipboardBroadcastChannel ] ) ;
84+ } , [ ] ) ;
6785
6886 const isPasteable = canEdit && clipboardData ?. content ?. status !== CLIPBOARD_STATUS . expired ;
6987 const showPasteUnit = isPasteable && clipboardData ?. content ?. blockType === 'vertical' ;
0 commit comments