v

Chrome extensions on Manifest V3

Manifest V3 removes two things that extension UIs used to lean on:

Together these rule out most of the “sprinkle reactivity onto HTML” libraries, because compiling x-on:click="count++" into a function is exactly what MV3 forbids. Alpine’s CSP build exists for this, at the cost of a restricted expression subset; petite-vue has no CSP mode at all.

The two escape hatches

A sandboxed iframe. MV3 still permits eval inside a sandboxed page, so you can run a normal library there and message it. It works, and it costs you a document boundary: the sandbox has no extension API access, so every read and write becomes postMessage plumbing. Reasonable for a templating engine you cannot replace, heavy for a popup with three buttons.

An interpreter instead of a compiler. If expressions are parsed and walked rather than compiled to a function, nothing is ever evaluated as a string and the policy is satisfied without a sandbox. This is what sprae’s CSP build does, using jessie.

A working popup

Three files, no build step, no bundler.

// manifest.json
{
  "manifest_version": 3,
  "name": "Example",
  "version": "1.0",
  "action": { "default_popup": "popup.html" },
  "permissions": ["storage"]
}

Download sprae-csp.umd.js into the extension folder, then:

<!-- popup.html -->
<!doctype html>
<meta charset="utf-8">
<script src="sprae-csp.umd.js" data-start></script>

<div :scope="{ sites: [], draft: '' }"
  :mount="s => chrome.storage.sync.get('sites', r => s.sites = r.sites || [])"
  :fx="chrome.storage.sync.set({ sites })">

  <input :value="draft" :change="v => draft = v"
    :onkeydown.enter="sites.push(draft), draft = ''"
    placeholder="Block a site..." />

  <ul>
    <li :each="site, i in sites">
      <span :text="site"></span>
      <button :onclick="sites.splice(i, 1)">×</button>
    </li>
  </ul>

  <p :if="!sites.length">Nothing blocked yet.</p>
</div>

That is the whole popup. The arrow functions, the array methods and the chrome.* global calls all run under script-src 'self', because none of them are compiled from a string.

What still differs from the eval build

The full test suite runs against the CSP build on every commit, and three differences remain:

Content scripts

The same file works in a content script, with one caveat: a content script shares the host page’s DOM but runs under the extension’s policy, so bundle the script rather than injecting a CDN tag.

"content_scripts": [{
  "matches": ["https://example.com/*"],
  "js": ["sprae-csp.umd.js", "init.js"]
}]

More on policies, headers and the Alpine comparison: strict CSP and Alpine’s CSP build.