Skip to content

feat(vite-plugin): Shadow DOM isolation for content scripts#1144

Open
Toumash wants to merge 7 commits into
crxjs:mainfrom
Toumash:feat/shadow-dom-content-scripts
Open

feat(vite-plugin): Shadow DOM isolation for content scripts#1144
Toumash wants to merge 7 commits into
crxjs:mainfrom
Toumash:feat/shadow-dom-content-scripts

Conversation

@Toumash
Copy link
Copy Markdown
Member

@Toumash Toumash commented Mar 25, 2026

Summary

Adds opt-in Shadow DOM support for content scripts, providing CSS/DOM isolation so host page styles don't affect the extension UI and vice versa.

Motivation

Content scripts inject UI directly into the host page's DOM, which means the host page's CSS can unintentionally break extension styles (and vice versa). Shadow DOM provides a native browser isolation boundary. While users can create shadow DOM manually, making Vite's CSS injection and HMR work inside shadow DOM requires patching Vite client internals — which CRXJS already does for other purposes.

How it works

Configuration

// vite.config.ts — global opt-in
crx({
  manifest,
  contentScripts: { shadowDom: true }        // open mode (default)
  // or: contentScripts: { shadowDom: { mode: 'closed' } }
})

Per-script override via __crx_shadowDom field in the manifest (stripped from output).

Content script API

// content.ts
import './style.css'

export function onExecute({ shadowRoot }: ContentScriptAPI.ExecuteFnOptions) {
  const app = document.createElement('div')
  app.innerHTML = `<h1>Isolated UI</h1>`
  shadowRoot.appendChild(app)
}

Production (build)

  • Shadow loader creates <crx-root> element, attaches shadow root
  • CSS loaded via adoptedStyleSheets using fetch() + CSSStyleSheet.replaceSync()
  • CSS files automatically added to web_accessible_resources
  • Uses a placeholder mechanism so CSS output filenames are resolved after Vite's build manifest is available (during renderCrxManifest)

Development (serve)

  • Shadow loader sets globalThis.__CRX_SHADOW_ROOT__ before importing the content script
  • Patches HTMLHeadElement.prototype.appendChild/removeChild to redirect <style data-vite-dev-id> elements to the shadow root
  • Patches Document.prototype.querySelector so Vite can find existing styles in the shadow root for HMR deduplication
  • CSS HMR works without full page reload

Scope

  • Only affects ISOLATED world content scripts (MAIN world scripts are unaffected)
  • No portal helper utilities — users handle React/framework portals themselves

Changes

New files

File Description
src/client/iife/content-dev-loader-shadow.ts Dev mode shadow DOM IIFE loader
src/client/iife/content-pro-loader-shadow.ts Production shadow DOM IIFE loader
tests/e2e/mv3-vite-vanilla-content-script-shadow-dom/ Full E2E test suite

Modified files

File Description
src/node/types.ts Added shadowDom to CrxOptions.contentScripts
src/node/contentScripts.ts Added shadowDom/shadowMode to ContentScript interface, shadow loader factories, CSS placeholder
src/node/plugin-contentScripts.ts Three-way branching (MAIN / shadow / regular) for loader creation
src/node/plugin-manifest.ts Shadow DOM resolution from config + per-script override, stripping from output
src/node/plugin-fileWriter-polyfill.ts CSS shadow root redirection patches for dev mode
src/node/plugin-contentScripts_css.ts Shadow DOM scripts skip manifest CSS injection; post-processes shadow loader assets to replace CSS placeholder with actual URLs
src/node/plugin-webAccessibleResources.ts Shadow DOM CSS files added to WAR for fetch() access
client.d.ts Added shadowRoot to ExecuteFnOptions

Testing

E2E tests cover:

  • Build: Shadow root created, content rendered, extension CSS applied inside shadow DOM
  • Build: Host page CSS (!important via MAIN world script) does NOT penetrate shadow boundary
  • Serve: Shadow DOM renders correctly, CSS isolation works
  • Serve: CSS HMR updates inside shadow DOM without page reload
  • Serve: Host page <h1> stays styled by MAIN world script after HMR

Full suite results:

  • 120 passed, 4 skipped, 1 pre-existing flaky failure (unrelated seq-hmr 100ms timeout)
  • Zero regressions

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 25, 2026

🦋 Changeset detected

Latest commit: a7b4914

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@crxjs/vite-plugin Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@nizoio
Copy link
Copy Markdown

nizoio commented Apr 22, 2026

a small nice thing to have is the ability to change "crx-root" name through the settings:

crx({
  manifest,
 contentScripts: { shadowDom: { mode: 'open', wrapper: "crxjs-custom-root" } }
});

@Toumash also just wanted to ask why this hasn't been merged yet 🤔?

@Toumash
Copy link
Copy Markdown
Member Author

Toumash commented Apr 22, 2026

@nizoio review needed. Not super strict as that is a opt-in feature, but still + broken ci test to be fixed/investigated

@Toumash Toumash requested a review from FliPPeDround April 22, 2026 17:57
@Toumash
Copy link
Copy Markdown
Member Author

Toumash commented Apr 22, 2026

ok so the test works

@nizoio
Copy link
Copy Markdown

nizoio commented Apr 22, 2026

I tested this PR using patch-package, but I ran into a strange development bug: Shadow DOM style changes don’t apply on the first attempt for some reason (updates are always one step behind HMR changes).

For example, changing a Vue component’s styles from:

.button { background: red; }

doesn’t work.

If I then change it again to:

.button { background: blue; }

the button turns red (not blue).

it is always one change behind.

It seems like there’s a timing issue somewhere. I’m no expert in this, but I spent some time trying to figure out the root cause but no luck 😬

@Toumash any idea why this is happening? 🤔

@Toumash
Copy link
Copy Markdown
Member Author

Toumash commented Apr 23, 2026

@nizoio i'll create a vue e2e test then and we'll see

Toumash added a commit to Toumash/chrome-extension-tools that referenced this pull request Apr 23, 2026
…heetsMap reuse

PR crxjs#1144 feedback from @nizoio: with shadowDom content scripts, the
second sequential Vue SFC <style scoped> HMR update silently no-op'd
because Vite's client cache (sheetsMap) reuses the cached <style>
element and writes via style.textContent = newCss, which the Vue
scoped-style HMR path can lose to an in-flight fetch for the prior
edit.

The polyfill injected into @vite/client now:

- Redirects fresh dev-style inserts into the shadow root (existing
  behaviour) and evicts any prior style with the same data-vite-dev-id
  so stale sheets cannot linger.
- Hooks HTMLStyleElement.prototype textContent: for shadow-attached
  dev styles, writes land on the live shadow-root node and mirror
  onto the caller, so a reused cache ref cannot stick with stale CSS.

Also adds unit tests documenting the runtime contract (jsdom, <10ms)
and a 2-edit Vue shadow-DOM E2E (10/10 green over repeated runs).

A 3rd sequential edit exposes a separate upstream bug where Vite never
delivers the third updateStyle call to the client at all; captured in
a sibling test.skip so the symptom is reproducible in-tree.
Toumash added 7 commits April 23, 2026 18:55
Add opt-in shadow DOM support for content scripts, providing CSS/DOM
isolation so host page styles don't affect extension UI and vice versa.

Activated via contentScripts.shadowDom in CrxOptions (global default)
or per-script __crx_shadowDom field in the manifest.

- Dev mode: patches Vite's style injection to redirect CSS into shadow root
- Production: loads CSS via adoptedStyleSheets using fetch() + CSSStyleSheet
- MAIN world scripts are unaffected (shadow DOM only for ISOLATED world)
- CSS files for shadow DOM scripts added to web_accessible_resources
- Uses placeholder mechanism for production CSS URLs (resolved during
  renderCrxManifest after Vite build manifest is available)
- E2E tests verify isolation in both directions: extension CSS stays in
  shadow DOM, and host page CSS (injected via MAIN world script with
  !important) does not penetrate shadow boundary

Closes crxjs#1142
Refs crxjs#1143
…heetsMap reuse

PR crxjs#1144 feedback from @nizoio: with shadowDom content scripts, the
second sequential Vue SFC <style scoped> HMR update silently no-op'd
because Vite's client cache (sheetsMap) reuses the cached <style>
element and writes via style.textContent = newCss, which the Vue
scoped-style HMR path can lose to an in-flight fetch for the prior
edit.

The polyfill injected into @vite/client now:

- Redirects fresh dev-style inserts into the shadow root (existing
  behaviour) and evicts any prior style with the same data-vite-dev-id
  so stale sheets cannot linger.
- Hooks HTMLStyleElement.prototype textContent: for shadow-attached
  dev styles, writes land on the live shadow-root node and mirror
  onto the caller, so a reused cache ref cannot stick with stale CSS.

Also adds unit tests documenting the runtime contract (jsdom, <10ms)
and a 2-edit Vue shadow-DOM E2E (10/10 green over repeated runs).

A 3rd sequential edit exposes a separate upstream bug where Vite never
delivers the third updateStyle call to the client at all; captured in
a sibling test.skip so the symptom is reproducible in-tree.
Rename src1/main.ts and src4/main.ts to main.js and update manifest to
match. The existing mv3-vite-vue-content-script-hmr fixture uses
main.js for the same reason: the repo lacks a vue-shim.d.ts, so tsc
can't resolve './App.vue' imports from TypeScript entries and
lint:types fails CI.
Remove identical-content duplicates of App.vue and main.js from src4/.
They were being overwritten by createUpdate's fs.copy on each test
run, bumping mtime and triggering spurious chokidar events that raced
with the real HelloWorld.vue change. Under CRX's extra file-writer
watcher pressure, Vite 3's HMR coalescing would silently drop the
real ws.send({type:'update'}) ~30-50% of the time.

With src4/ containing only the one file that actually differs, the
test is deterministic: rename vite-serve-3edits-upstream-bug.test.ts
to vite-serve-3edits.test.ts, drop the test.skip wrapper and the
"upstream bug" documentation, and keep it as a proper regression
test for the shadow-DOM style HMR fix.
Adds a plain Vite + @vitejs/plugin-vue dev-server fixture with no CRX
plugin and no extension loading, running the same 3 sequential style
edits. Serves as a regression control: if the CRX fixture and this
one disagree, the bug is CRX-side; if they agree, it's upstream.

Also document the fixture hygiene rule on createUpdate: src2/, src3/,
... must contain only files whose content differs from src1/, because
fs.copy with overwrite still rewrites identical files and can
provoke watcher events that race with real HMR updates.
@Toumash Toumash force-pushed the feat/shadow-dom-content-scripts branch from 48ab8b7 to a7b4914 Compare April 23, 2026 16:58
@Toumash
Copy link
Copy Markdown
Member Author

Toumash commented Apr 23, 2026

@nizoio updated the test and fix

@salmin89
Copy link
Copy Markdown
Contributor

salmin89 commented Apr 23, 2026

If you set

contentScripts: { shadowDom: true }

will this only affect content scripts that export onExecute?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Can the style isolation of Google extensions be integrated into the framework?

3 participants