Skip to content

Feat/1101 support iife content scripts#1158

Merged
Toumash merged 23 commits into
crxjs:mainfrom
salmin89:feat/1101--iife-content-scripts
Jun 7, 2026
Merged

Feat/1101 support iife content scripts#1158
Toumash merged 23 commits into
crxjs:mainfrom
salmin89:feat/1101--iife-content-scripts

Conversation

@salmin89

@salmin89 salmin89 commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Hey guys!

Full disclosure:

I used Agents to heavily assist with this PR. This feature is something we've needed at my company for a while, but I never had enough time to learn CrxJS enough to be able to it without help from AI. My strategy was to write tests to make sure we didn't break any existing functionality.

This feature would solve a number of open issues (some of which would need to accept the iife lifestyle)
#1102
#1101
#1152
#1119

The following below is what the AI generated to describe this PR.


feat: Add IIFE content script bundling

Summary

This PR adds support for bundling content scripts as self-contained IIFE (Immediately Invoked Function Expression) files. Content scripts named with the .iife.ts extension are automatically detected and built as single files with all dependencies inlined.

Motivation

When injecting content scripts into the MAIN world using chrome.scripting.executeScript or chrome.scripting.registerContentScripts, ES modules are not supported. This requires content scripts to be bundled as IIFE files with no external imports.

Previously, developers had to manually configure separate build steps or use workarounds. This PR adds first-class support for IIFE content scripts with zero configuration.

Usage

1. Manifest-declared content scripts

Name your content script with .iife.ts extension:

src/content/injected.iife.ts

import { someHelper } from './utils'

console.log('Running in MAIN world!', someHelper())

manifest.config.ts

export default defineManifest({
  content_scripts: [
    {
      js: ['src/content/injected.iife.ts'],
      matches: ['https://*/*'],
      world: 'MAIN',
    },
  ],
})

2. Dynamic content scripts (chrome.scripting API)

src/background.ts

import scriptPath from './content/injected.iife.ts?script'

chrome.action.onClicked.addListener((tab) => {
  chrome.scripting.executeScript({
    target: { tabId: tab.id },
    world: 'MAIN',
    files: [scriptPath],
  })
})

Output

Regular content script (main.ts):

  • Uses loader pattern with code-splitting
  • Output: assets/main.ts-loader-xxx.js + chunks

IIFE content script (main.iife.ts):

  • Single self-contained file with all imports inlined
  • Output: src/content/main.iife.js
// Built output of .iife.ts file
(function(){"use strict";function helper(){return"inlined"}console.log("Running!",helper())})();

Benefits

  • Zero configuration - Just name your file .iife.ts
  • All imports inlined - No external dependencies or dynamic imports
  • MAIN world compatible - Works with chrome.scripting.executeScript
  • Works with ?script imports - Auto-detected for dynamic content scripts

Trade-offs

  • No code sharing between IIFE content scripts (each is fully self-contained)
  • Separate build step per IIFE script (slightly longer build time)

Testing

  • Added unit tests for isIifeContentScript filename detection (10 tests)
  • Added E2E tests for build output verification
  • Added E2E tests for dev server compatibility

Add support for bundling content scripts as self-contained IIFE files.
Content scripts named with .iife.ts extension are automatically detected
and built as single files with all dependencies inlined.

This is useful for:
- MAIN world content scripts (chrome.scripting.executeScript)
- Avoiding loader overhead
- Single-file distribution

The IIFE plugin uses Vite's library mode to build each .iife.ts file
separately, producing a single bundled output per content script.
- Update manifest plugin to skip emitting .iife.ts files (handled by IIFE plugin)
- Update content scripts plugin to skip IIFE type processing
- Update dynamic content scripts plugin to auto-detect .iife.ts files
- Add enforce: 'post' to dynamic scripts build hook for correct ordering
@changeset-bot

changeset-bot Bot commented Apr 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e3c75f9

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

@salmin89 salmin89 force-pushed the feat/1101--iife-content-scripts branch 2 times, most recently from c9aa0b8 to 319d4ab Compare April 24, 2026 01:54
- Add unit tests for isIifeContentScript filename detection
- Add E2E build test verifying IIFE output format and inlined imports
- Add E2E serve test verifying .iife.ts files work in dev mode
- Test both manifest-declared and dynamic (?script) content scripts
@salmin89 salmin89 force-pushed the feat/1101--iife-content-scripts branch from 319d4ab to 0f729b4 Compare April 24, 2026 01:58
@Toumash

Toumash commented Apr 24, 2026

Copy link
Copy Markdown
Member

hm whats the difference #1101 / #1102 other than .iife.js vs ?iife? @salmin89

@salmin89

Copy link
Copy Markdown
Contributor Author

I'd love to see what @jacksteamdev thinks about this solution

@salmin89

salmin89 commented Apr 24, 2026

Copy link
Copy Markdown
Contributor Author

hm whats the difference #1101 / #1102 other than .iife.js vs ?iife? @salmin89

Initially when I tested 1102 it didn't do what I needed it to do. I see that you ended up updating the PR and got it working (I missed the update).

I would say that this PR does the same thing as #1102 does, but also adds support for IIFE in the manifest (by naming .iife).


I just added a comment to your PR while testing it out:
#1102 (comment)

Toumash added 8 commits June 4, 2026 18:06
Backports the ergonomic `?iife` import syntax from crxjs#1102 as a small extension on top of crxjs#1158.

- Add `declare module '*?iife'` (alias to `'*?script&iife'`) in client.d.ts for TypeScript support.
- Update dynamic content scripts loader to accept bare `?iife` (in addition to `?script&iife` and the `.iife.*` filename convention).
- In build replacement, output IIFE paths without leading `/` (registerContentScripts requirement).

This keeps full compatibility with the `.iife.*` + manifest support and auto-detection introduced in crxjs#1158 / crxjs#1101.

Refs: crxjs#1102, crxjs#1158, crxjs#1101
…?iife) content scripts

Ports and adapts the dev IIFE support from crxjs#1102 so that `type: 'iife'` scripts (from bare `?iife`, `?script&iife`, or `.iife.*?script`) are actually emitted as self-contained IIFE bundles during `vite serve`, not just at build time.

- Add `bundleIife` (using `viteBuild` with `configFile: false` + copied resolve settings to avoid Vite 6+ WeakMap/plugin reuse issues, same fix as in crxjs#1102).
- Add `prepIifeScript` operator (inlines all deps, writes extra assets like sourcemaps).
- Branch in `prepScript` / `prepFileData` so IIFE goes through the dedicated bundler instead of normal transform + es-module-lexer rewrite.
- Wire in `plugin-contentScripts` (dev `contentScripts.change$` subscription): call `add({ type: 'iife', id })` instead of skipping (the skip comment was for the build-only IIFE plugin).
- Update error message in `getViteUrl`.

This enables proper MAIN world dynamic scripts (via `registerContentScripts({ world: 'MAIN' })`) and other IIFE use cases to work in development, including rebuilds.

Build-time IIFE (the dedicated `plugin-contentScripts_iife`) from crxjs#1158 is untouched.

Refs: crxjs#1102, crxjs#1158, crxjs#1101
Ports the IIFE-specific hot update handling from crxjs#1102.

- In `handleHotUpdate`, compute `changedFilePath` from the raw `file` (IIFE entries are not part of Vite's module graph, so normal `modules` / `relFiles` detection misses them).
- For any `contentScripts` entry with `type === 'iife'` whose source matches the changed file: call `update()`, wait for the rebuild promise, then send `crx:runtime-reload` (if liveReload enabled).
- Includes debug logging.

Reason: Chrome caches content scripts registered with `registerContentScripts` / `executeScript`, so source changes require a full extension reload even in dev. This was already necessary for declared MAIN world scripts and is now generalized.

Complements the dev bundling from the previous commit.

Refs: crxjs#1102, crxjs#1158
…cripts

Adds the test coverage from crxjs#1102 (adapted to run on top of the crxjs#1158 base):

- `tests/e2e/mv3-dynamic-script-iife/`: full end-to-end test for dynamic IIFE via `?iife` (bare) + `registerContentScripts({ world: 'MAIN' })`. Includes rebuild-on-change test (copies src1 → src → src2 and verifies runtime reload + updated content after page reload).
- `tests/out/dynamic-script-iife/`: build and serve output snapshots for dynamic IIFE scripts.

These tests specifically exercise:
- Bare `?iife` import alias
- Dev-time IIFE bundling (self-contained output)
- HMR → rebuild → `crx:runtime-reload` flow for IIFE
- Correct path format (no leading `/`) for registerContentScripts

The existing `mv3-content-script-iife` e2e from crxjs#1158 (manifest + dynamic with `.iife.*` naming) remains.

Refs: crxjs#1102, crxjs#1158
…ame convention + query alias)

- In the mv3-content-script-iife e2e (filename convention + ?script on .iife files): add explicit bare `?iife` import on a normal-named file. This exercises the query alias alongside the filename convention in the same project.
- In the mv3-dynamic-script-iife e2e (bare ?iife + MAIN world): add a `*.iife.ts` file + import via `?script` to also exercise the filename convention in the test for the query approach.
- Added a small convention-named file so both styles are used in the same fixture.

Now the end-to-end tests on this combined branch verify that **both approaches work** (and can even be mixed in one extension):
- Filename convention: naming `foo.iife.ts` (auto-detected for manifest and dynamic `?script`)
- Query alias: any file + bare `?iife` or `?script&iife` query

Refs: crxjs#1102, crxjs#1158
…st (Salmin approach via config)

Adds coverage for Salmin's IIFE filename convention when the manifest is provided via defineManifest in manifest.config.ts (instead of static JSON).

This exercises the path where the manifest is a function (resolved in plugin-contentScripts_iife and plugin-manifest), the content_scripts are read from it, and isIifeContentScript detection is applied to the listed JS files.

Previously the e2e only tested the static manifest.json case for declared IIFE content scripts.

Refs: crxjs#1158
…Scripts.standalone option

Adds support for declaring IIFE/standalone content scripts without requiring the .iife.* filename convention.

- New option: contentScripts.standalone: string[] in crx() plugin options (or vite.config).
- Normal .ts/.js files listed in manifest.content_scripts + marked in standalone will be built as self-contained IIFE.
- Updated detection in plugin-manifest (skip normal emit), plugin-contentScripts_iife (collect and build), and dynamic plugin (auto iife type for ?script on standalone files).
- Updated the mv3-content-script-iife e2e test to use defineManifest + standalone for a normal-named file (content-standalone.ts), in addition to the .iife convention.
- Tests now cover explicit 'define standalone files' in config (as discussed for Salmin's approach).

This allows keeping regular filenames while getting IIFE behavior for MAIN world etc.

Refs: crxjs#1158 (extension)
…cussion

- All references now use the nested form under contentScripts for grouping
  with other content script options (preamble, hmrTimeout, injectCss).
- This makes sense because 'standalone' treatment is a content script
  processing mode (IIFE instead of loader), not a completely separate
  top-level concept.
- Updated types, all plugins, comments, and the e2e test config.
- The feature still allows normal-named files in defineManifest without
  .iife suffix by listing them in standaloneFiles.
Toumash and others added 5 commits June 5, 2026 12:02
…s + fix paths + clean ts comments

- Add dedicated normal-named file exercised via bare ?iife query string
- Register it dynamically and assert runtime marker (full coverage for filename convention, standaloneFiles config, and ?iife import)
- Fix leading '/' in non-IIFE dynamic script paths returned from ?script (required for chrome.scripting.registerContentScripts)
- Remove obsolete @ts-expect-error comments now that client.d.ts ambient declarations make the virtual imports type-check cleanly
- Update output snapshots for changed dynamic script filenames and IIFE output locations

This makes the e2e test actually validate that all three ways of marking content scripts as IIFE produce working, injectable scripts.
Prefer code simplicity over deduplicating output files for pure IIFE cases.
The separate IIFE artifact is still produced and used for registration/injection.
Pure IIFE dynamic scripts now leave their (unused at runtime) ESM chunk in assets/,
but mixed use (IIFE registration + normal static import) works automatically
without any reference scanning or conditional logic.

This reverts the dedup approach for simplicity as discussed.
There are three supported ways for a content script to become an IIFE bundle:

1. Filename convention: *.iife.ts / *.iife.js etc.
2. Query string on dynamic imports: ?iife (or ?script on an IIFE/standalone file).
3. Declarative: contentScripts.standaloneFiles in the crx() options inside
   the user's Vite defineConfig (or defineManifest-style config).

The previous attempt had dropped support for (3) in the IIFE plugin and
re-introduced fragile ESM chunk removal logic.

All explanatory comments (three modes + why we intentionally do not remove
the intermediate chunks emitted for dynamic IIFE cases) are now in a single
JSDoc block on pluginContentScriptsIife.

Also includes a minor comment cleanup in getViteUrl for the iife case.
The mv3-content-script-iife and mv3-dynamic-script-iife fixtures prepare
the src/ directory (copying src1, later switching files for rebuild tests).

fs.remove + plain copy was prone to ENOTEMPTY (on rmdir) and EEXIST (on mkdir)
when the full test matrix runs in one vitest process. Previous serve() calls
leave Vite watchers (with usePolling) and temp state that interfere with
immediate cleanup of the source dir for the next test.

Changed to fs.emptyDir + copy with { overwrite: true, recursive: true }.
This is the fs-extra recommended pattern for "reset this dir to a known state".

Note: this does not make the tests bulletproof (full isolation via per-test
temp dirs would be better), but reduces the flakiness for the exact tests
that exercise the new IIFE feature (standaloneFiles, .iife filenames, and
?iife query strings).
@salmin89 salmin89 force-pushed the feat/1101--iife-content-scripts branch from 045e4dd to c1fc77c Compare June 6, 2026 15:34
@salmin89 salmin89 force-pushed the feat/1101--iife-content-scripts branch from 9732180 to e3c75f9 Compare June 7, 2026 02:57

@Toumash Toumash left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@Toumash Toumash changed the title Feat/1101 iife content scripts Feat/1101 support iife content scripts Jun 7, 2026
@Toumash Toumash merged commit 565a8ba into crxjs:main Jun 7, 2026
14 checks passed
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.

2 participants