Content scripts

Send a message to the content script

Pass a small structured command from the popup to the page context and validate it before changing the DOM.

The popup knows the note. The content script can draw on the page. They live in different contexts and can’t call each other’s functions. So the popup sends a message, and the content script listens for it.

Sending from the popup

Once the content script is in the tab, we send it a command with chrome.tabs.sendMessage():

await chrome.tabs.sendMessage(tab.id, {
  type: 'SHOW_PAGE_NOTE',
  note
})

The message is a plain object with a type and the data the receiver needs. That’s the pattern I use everywhere: one string that says what to do, then the payload.

Receiving in the content script

On the other side, chrome.runtime.onMessage fires for every message that arrives. The listener checks the type, validates the fields, does the work, and answers:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== 'SHOW_PAGE_NOTE' || typeof message.note !== 'string') {
    sendResponse({ ok: false, code: 'INVALID_MESSAGE' })
    return
  }
  showPanel(message.note)
  sendResponse({ ok: true })
})

The type guard matters. A message is input crossing a boundary, like a form submission or an HTTP body. An old version of the popup, or a bug, can send something unexpected. On invalid input the page must stay untouched.

Returning an explicit result lets the popup tell the cases apart: shown, invalid note, restricted page, nobody listening.

What travels in a message

One-time messages are JSON-serialized. Strings, numbers, booleans, arrays, plain objects. Not functions, not DOM nodes, not class instances, not a Date (it becomes a string). Keep payloads small: send the note, not the whole storage.

When nobody is listening

tabs.sendMessage() rejects when there’s no content script in the tab. The classic case: you reloaded the extension, but the page still runs the old script, or no script at all.

Page Notes has two sane options. Inject first, then send, every time, relying on the idempotent setup from the previous lesson. Or send first and, on a “no receiver” error, inject once and retry once. What you must not do is inject blindly after every error, because “invalid message” is not “nobody home”.

Test the listener with an unknown type, with a number instead of a string for note, and with a message sent right after reloading the extension. The page must remain unchanged on the first two, and the popup must show a recovery path on the third, not a blank status.

Lesson completed