My hobby projects are small Chrome extensions and little single-purpose websites. One of those extensions, PDF to PNG, unexpectedly climbed to first place in Chrome Web Store searches for “pdf to png” (well, most of the time), and nearly 500 people use it - which is amazing!

Once PDF to images worked, I immediately wanted to do the reverse - images -> PDF, only better: right-click an image on any page, have an easy(!) way to adjust the settings, and do everything locally in the browser without uploading the images anywhere!

I put all those lovely settings in the side panel. Except a regular user might never think to open it, and I did want them to know it was there, without pestering people who were happy with the right-click menu. So I’d show it sometimes (or until the user actually clicked something in the panel).

Then I ran into a Chrome rule: you can only open the panel in response to a click, which, in a menu item click handler, means before the first await. And to show it sometimes, the extension needs to remember on each click how many times it has already shown it - but reading that from storage takes an await.

The solution - store the counter right in the context menu item’s id! :)

And here it is - Save Image as PDF. Right-click - and your image is already a PDF.

Await on a page and await in a worker “use up” the gesture differently

The right-click happens on someone else’s page, with no extension UI open: no panel, no extension pages. So a service worker listens for menu item clicks.

The naive approach is to read how many times the panel has been shown inside the click handler and use that to decide whether to open it:

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  const { shown = 0 } = await chrome.storage.local.get('shown');
  if (shown < 3) {
    chrome.sidePanel.open({ tabId: tab.id });
  }
});

Of course, the panel never opens, and every click produces sidePanel.open() may only be called in response to a user gesture in the worker’s console.

Some Chrome APIs, such as chrome.sidePanel.open() and chrome.permissions.request(), only work when called in response to a human action: a click or a keypress. Clicking a menu item counts as a gesture, but that gesture has a very different lifetime on a page and in a worker.

A regular page has transient activation: after a click, the gesture stays “alive” for 5 seconds. Chromium’s code says so explicitly: kActivationLifespan = base::Seconds(5), with the explanation “long enough to allow network round trips even in a very slow connection”. It’s easy to check - paste this into the DevTools console on any page and click the page:

document.onclick = () => [4000, 6000].forEach((ms) =>
  setTimeout(() => console.log(ms, navigator.userActivation.isActive), ms));
// 4000 true
// 6000 false

In a worker, there’s no timer: the gesture lasts only while Chrome is delivering the click event to the handler. An async handler returns control to Chrome at its very first await - and the gesture is gone. Even await Promise.resolve(), which doesn’t wait for anything, uses it up. You can still use await chrome.sidePanel.open() itself - just don’t await anything before it. The Chrome team knows about this difference: there’s a proposal in the WebExtensions CG to give extensions a time-based gesture lifetime, like pages have, but it’s still only a proposal.

Almost anything useful takes an await: reading settings, downloading an image, building a PDF. And the worker might have just woken up because of this very click: in MV3, it gets unloaded after about 30 seconds of inactivity - so much for determinism. After a cold start like that, its memory is empty, so a global variable won’t save you either. How do you know, at the moment of the click, how many times the panel has already been shown?

A callback instead of await

The chromium-extensions group has a workaround using a good old callback: Chrome preserves the “gesture” for exactly one callback. Just one, and it has to be a callback (no await!). A callback inside a callback won’t get the gesture.

chrome.contextMenus.onClicked.addListener((info, tab) => {
  chrome.storage.local.get('shown', ({ shown = 0 }) => {
    if (shown < 3) chrome.sidePanel.open({ tabId: tab.id });
    // this works with exactly one callback - no nesting
  });
});

But I managed to come up with another trick and stay in the world of await!

A counter in the menu item’s id

I came up with this rule to force make sure users discover the side panel without having it in their face all the time: open it for the first three saves, then every tenth save, until the user clicks anything in the panel or opens it themselves (meaning they’ve definitely noticed it). That means knowing, at the moment of the click, which save this is and which mode is selected in the settings.

One value I control arrives synchronously in the menu click handler: info.menuItemId, the id of the item the user clicked. It might not be obvious, but nothing stops you from recreating menu items twenty times a day. So that’s where the state went:

// id = 'save|<step>|<mode>'
//   step  0..12   user hasn't used the panel yet; count saves through the menu
//         known   user opened the panel themselves or did something in it; no longer changes
//   mode  download | panel | collection
export const NUDGE_SAVES = 3;
export const LAST_STEP = 12;
export const KNOWN = 'known';

const ID_PATTERN = /^save\|(known|0|[1-9]\d?)\|(download|panel|collection)$/;

export function parseMenuId(id: unknown): MenuState | null {
  …
  const match = ID_PATTERN.exec(id);
  …
  return { step, mode: match[2] as MenuMode };
}

export function shouldOpenPanel(state: MenuState): boolean {
  if (state.mode === 'panel') return true;
  if (state.step === KNOWN) return false;
  return state.step < NUDGE_SAVES || state.step === LAST_STEP;
}

export function afterMenuSave(state: MenuState): MenuState {
  const { step } = state;
  if (step === KNOWN) return state;
  return { ...state, step: step === LAST_STEP ? NUDGE_SAVES : step + 1 };
}

The extension has one menu item, always titled “Save image as PDF”. Only the id changes: save|0|download, save|12|collection, save|known|panel, and so on. The handler parses the id and, if needed, opens the panel as its very first call, without waiting for anything:

export function handleMenuClick(info, tab, deps): Promise<void> {
  const state = parseMenuId(info.menuItemId); // synchronously
  …
  const openedPanel = shouldOpenPanel(state) && tabId !== undefined;
  if (openedPanel) {
    deps.openPanel(tabId).catch(warn('sidePanel.open')); // chrome.sidePanel.open({ tabId })
  }
  …
}

After the click, once the gesture is gone, the worker calculates the next state and re-registers the item: contextMenus.removeAll() followed by create() with the new id. contextMenus.update() won’t help here: it can’t change the id. Chrome stores the menu registration itself, so it survives both worker unloads and browser restarts. Chrome does clear the menu when the extension updates, though, so I also keep a copy of the id in storage.local and restore the item from there.

Of course, you can’t fit much in an id - a counter and a mode. But nothing at all needs to be read before opening the panel; that’s a little faster than reading chrome.storage, though you won’t notice the difference in the UI.

If your extension has a dynamic menu too

If you’d also like a right-click to behave differently depending on the situation, here’s what worked for me:

  • put the call that needs a gesture on the first line of the handler, before any await;
  • put everything you need to know at click time in the menu item’s id, then re-register the item with a new id after the click;
  • well, the callback option works too

The extension itself is Save Image as PDF in the Chrome Web Store, and it also has a website. And if you need the reverse, PDF to images, there’s PDF to PNG, which started all this (website). More about it on the project page.

What about you?

How do you pass state into onClicked? If you’ve found something simpler than an id that doesn’t rely on a single callback, I’d love to hear about it.