feat(vite-plugin): Shadow DOM isolation for content scripts#1144
feat(vite-plugin): Shadow DOM isolation for content scripts#1144Toumash wants to merge 7 commits into
Conversation
🦋 Changeset detectedLatest commit: a7b4914 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
a small nice thing to have is the ability to change "crx-root" name through the settings: @Toumash also just wanted to ask why this hasn't been merged yet 🤔? |
|
@nizoio review needed. Not super strict as that is a opt-in feature, but still + broken ci test to be fixed/investigated |
|
ok so the test works |
|
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: doesn’t work. If I then change it again to: 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? 🤔 |
|
@nizoio i'll create a vue e2e test then and we'll see |
…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.
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.
48ab8b7 to
a7b4914
Compare
|
@nizoio updated the test and fix |
|
If you set will this only affect content scripts that export onExecute? |
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
Per-script override via
__crx_shadowDomfield in the manifest (stripped from output).Content script API
Production (build)
<crx-root>element, attaches shadow rootadoptedStyleSheetsusingfetch()+CSSStyleSheet.replaceSync()web_accessible_resourcesrenderCrxManifest)Development (serve)
globalThis.__CRX_SHADOW_ROOT__before importing the content scriptHTMLHeadElement.prototype.appendChild/removeChildto redirect<style data-vite-dev-id>elements to the shadow rootDocument.prototype.querySelectorso Vite can find existing styles in the shadow root for HMR deduplicationScope
Changes
New files
src/client/iife/content-dev-loader-shadow.tssrc/client/iife/content-pro-loader-shadow.tstests/e2e/mv3-vite-vanilla-content-script-shadow-dom/Modified files
src/node/types.tsshadowDomtoCrxOptions.contentScriptssrc/node/contentScripts.tsshadowDom/shadowModetoContentScriptinterface, shadow loader factories, CSS placeholdersrc/node/plugin-contentScripts.tssrc/node/plugin-manifest.tssrc/node/plugin-fileWriter-polyfill.tssrc/node/plugin-contentScripts_css.tssrc/node/plugin-webAccessibleResources.tsfetch()accessclient.d.tsshadowRoottoExecuteFnOptionsTesting
E2E tests cover:
!importantvia MAIN world script) does NOT penetrate shadow boundary<h1>stays styled by MAIN world script after HMRFull suite results:
seq-hmr100ms timeout)