The Error
While building Tuneo, I added a pin icon in the popup that opens the Chrome side panel for the current tab. The first click worked fine. But clicking the pin again after the popup reopened would throw:
Uncaught TypeError: Error in invocation of sidePanel.open():
sidePanel.open() may only be called in response to a user gesture.Why This Happens
Chrome MV3 introduced strict user-gesture requirements for certain APIs, includingchrome.sidePanel.open(). A “user gesture” means the call must originate from the same synchronous execution context as a user-initiated event — a click, keypress, touch, etc. If your code does any of the following between the gesture and the open() call, the gesture is invalidated:
awaiton a PromisesetTimeout/setIntervalrequestAnimationFramePromise.then()- Any async function boundary
In my case, the pin icon's click handler first fetched the current tab viachrome.tabs.query() (which returns a Promise), then calledsidePanel.open() after the await. Even though the user clicked the button, the gesture chain was broken by the async gap:
// User gesture lost — async gap between click and open()
pinButton.onClick = async () => {
const [tab] = await chrome.tabs.query({ active: true })
chrome.sidePanel.open({ tabId: tab.id }) // ❌ gesture lost
}The Fix
The solution is straightforward: call chrome.sidePanel.open()in the same synchronous tick as the click handler. You can prepare the tab ID beforehand or use a cached reference. In Tuneo, I already had the tab context from the popup'swindow reference, so I moved the open() call before any async work:
// open() fires synchronously inside the click handler
const handlePin = () => {
chrome.sidePanel.open({ tabId: currentTabId }) // ✅ gesture preserved
doOtherAsyncStuff() // async work after
}If you don't have the tab ID cached, get it outside the event handler (e.g., in auseEffect or on popup mount) and store it in a ref or variable. The key rule: no async gap between the user event and the API call.
Additional Considerations
This restriction applies to several Chrome MV3 APIs beyond sidePanel:chrome.windows.create(),chrome.tabs.create(),chrome.tabs.update(),chrome.tabs.remove(), and chrome.tabs.goForward() /goBack(). All share the same user-gesture requirement.
A common workaround pattern is to use a hidden “execution bridge” — achrome.runtime.onMessagelistener in the background that receives a synchronous message from the popup and performs the restricted API call. Since the message is sent from a user-gesture context, the background's handler inherits the gesture.
// popup — send message synchronously from click handler
onClick = () => {
chrome.runtime.sendMessage({ action: 'openPanel', tabId })
}
// background — handler inherits the gesture
chrome.runtime.onMessage.addListener((msg) => {
if (msg.action === 'openPanel')
chrome.sidePanel.open({ tabId: msg.tabId }) // ✅ gesture preserved
})