Skip to content

fix(plugins): make a module plugin actually re-evaluate on reload (#879) - #897

Merged
byrongamatos merged 1 commit into
mainfrom
fix/879-module-rollback
Jul 11, 2026
Merged

byrongamatos merged 1 commit into
mainfrom
fix/879-module-rollback

Conversation

@byrongamatos

@byrongamatos byrongamatos commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Closes #879.

A plugin reload silently did nothing for scriptType: "module" plugins. ES modules are evaluated once per URL per document, so re-inserting a <script type="module"> whose src the module map has already seen fires load without re-running the body — and the loader then recorded the reload as applied. A no-op that reported success.

The issue understates it — upgrades were broken too

#879 says "upgrades are fine — a new version yields a new URL". That is true of screen.js and false of the plugin.

I drove a real browser through install(1.0.0) → upgrade(1.1.0) → rollback(1.0.0), counting evaluations of src/main.js:

1

Not three. Not two. One.

The upgrade re-runs the one-line screen.js shim at its new ?v= URL; the shim does import './src/main.js'; a relative specifier resolves against the base URL with the query string dropped; that's the same URL as before; the module map hands back the already-evaluated v1.0.0 module. The plugin's own code never re-ran.

Busting the entry point cannot fix this, whatever token you hang off it.

The fix: the token goes in the PATH

/api/plugins/<id>/g/<n>/screen.js. From there './src/main.js' resolves to /api/plugins/<id>/g/<n>/src/main.jsevery relative import inherits the token, at every depth, for free. No import-specifier rewriting (which could never see import(expr) anyway).

Same browser drive after the fix: 3 evaluations.

evaluations of src/main.js
query token (before) 1
path token (after) 3

Keyed on the plugin id, not id@versionevery re-load of a module plugin needs a fresh path, not just a rollback. First load keeps the stable ?v= URL, so the ETag/304 live-edit caching the R0 rails depend on is untouched. Classic-script plugins are unaffected and never take a /g/ path.

A path rewrite, not two mirrored routes

Codex caught this, and it was right. The token shifts the base URL, so everything the module graph resolves relatively moves with it — not only imports. new URL('../assets/worklet.js', import.meta.url) from /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/worklet.js.

Mirroring only screen.js and src/ would have fixed imports and 404'd every asset, worklet and wasm file the graph reaches — and would have broken again the next time someone added a plugin route.

So the /g/<token> segment is stripped before routing. Every plugin route, present and future, works under the prefix with no extra wiring. The token is opaque and never joined into a filesystem path, so containment still rests entirely on the same safe_join.

Codex then caught a [P3] in that: eagerly re-encoding raw_path with latin-1 raises UnicodeEncodeError on a valid plugin file like src/工具.js, 500ing a request the plain route serves fine. raw_path is informational and Starlette routes on scope["path"], so the mutation is simply gone — and leaving raw_path as the client sent it is more truthful for logs anyway.

Tests

tests/js/plugin_module_rollback.test.js (5) + 8 new in test_plugin_src_route.py: identical bytes under the prefix; the whole graph one and two levels deep; assets (the [P2]); every plugin route; non-ASCII filenames (the [P3]); an opaque token; and containment asserted as parity with the un-prefixed route rather than a guessed 404 — ../screen.js legitimately 200s on both, because the URL normalises before routing ever happens.

All bite-tested: reverting the fix fails the rollback tests; disabling the rewrite fails the asset tests.

Two harnesses re-anchored on script.src = _pluginScriptUrl( — the URL literal they keyed on now lives in the helper, further down the file, so their slice ran off the end of the injection block.

node 1045 · pytest 2404 · ESLint 0 · Codex 0.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved plugin upgrades and rollbacks for module-based plugins, ensuring fresh versions and dependencies load correctly.
    • Added cache-busting support for plugin module resources without changing their served content.
    • Preserved existing behavior for classic, non-module plugins.
    • Maintained secure path handling and support for plugin scripts, assets, settings, nested imports, and non-ASCII filenames.

A plugin reload silently did nothing for scriptType:"module" plugins. ES modules are
evaluated ONCE PER URL PER DOCUMENT, so re-inserting a <script type="module"> whose src
the module map has already seen fires `load` without re-running the body — and the loader
then recorded the reload as applied. A no-op that reported success.

THE ISSUE UNDERSTATES IT. #879 says "upgrades are fine — a new version yields a new URL".
That is true of screen.js and FALSE of the plugin. I drove a real browser through
install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0), counting evaluations of src/main.js:

    ONE.

Not three, not two. The upgrade re-runs the one-line screen.js shim at its new ?v= URL;
the shim does `import './src/main.js'`; a relative specifier resolves against the base URL
WITH THE QUERY DROPPED; that is the same URL as before; the module map hands back the
already-evaluated v1.0.0 module. The plugin's own code never re-ran. Busting the entry
point cannot fix this, whatever token you hang off it.

So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. From there
'./src/main.js' resolves to /api/plugins/<id>/g/<n>/src/main.js — every relative import
inherits it, at every depth, for free. No import-specifier rewriting (which could never
see `import(expr)` anyway). Same browser drive after the fix: THREE evaluations.

Keyed on the plugin ID, not id@version: EVERY re-load of a module plugin needs a fresh
path, not just a rollback. First load keeps the stable ?v= URL, so the ETag/304 live-edit
caching the R0 rails depend on is untouched. Classic-script plugins are not affected and
never take a /g/ path.

━━━ A PATH REWRITE, NOT TWO MIRRORED ROUTES ━━━

Codex caught this, and it was right. The token shifts the BASE URL, so EVERYTHING the
module graph resolves relatively moves with it — not only imports.
`new URL('../assets/worklet.js', import.meta.url)` from /api/plugins/x/g/1/src/main.js
resolves to /api/plugins/x/g/1/assets/worklet.js. Mirroring only screen.js and src/ would
have fixed imports and 404'd every asset, worklet and wasm file the graph reaches — and
would have broken again the next time someone added a plugin route.

So the /g/<token> segment is STRIPPED BEFORE ROUTING. Every plugin route, present and
future, works under the prefix with no extra wiring. The token is opaque and never joined
into a filesystem path, so containment still rests entirely on the same safe_join.

Codex then caught a [P3] in that: eagerly re-encoding raw_path with latin-1 raises
UnicodeEncodeError on a valid plugin file like src/工具.js, 500ing a request the plain
route serves fine. raw_path is informational and Starlette routes on scope["path"], so the
mutation is simply gone — and leaving raw_path as the client sent it is more truthful for
logs anyway.

TESTS. tests/js/plugin_module_rollback.test.js (5) + 8 in test_plugin_src_route.py:
identical bytes under the prefix, the whole graph one and two levels deep, ASSETS (the
Codex [P2]), every plugin route, non-ASCII filenames (the [P3]), an opaque token, and
containment asserted as PARITY with the un-prefixed route rather than a guessed 404 —
`../screen.js` legitimately 200s on both, because the URL normalises before routing.
All bite-tested: reverting the fix fails the rollback tests, disabling the rewrite fails
the asset tests.

Two harnesses re-anchored on `script.src = _pluginScriptUrl(` — the URL literal they keyed
on now lives in the helper, further down the file, so their slice ran off the end.

node 1045, pytest 2404, ESLint 0, Codex 0.

Closes #879

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ea8d1f18-b452-47c0-938b-ed55f71e3cb3

📥 Commits

Reviewing files that changed from the base of the PR and between bd83032 and cfc1b0d.

📒 Files selected for processing (6)
  • plugins/__init__.py
  • static/js/plugin-loader.js
  • tests/js/legacy_shim_hits.test.js
  • tests/js/plugin_loader_script_type.test.js
  • tests/js/plugin_module_rollback.test.js
  • tests/test_plugin_src_route.py

📝 Walkthrough

Walkthrough

Adds generation-prefixed URLs for reloading ES-module plugins, rewrites those paths through existing plugin routes, and tests module graph resolution, containment, assets, Unicode filenames, and classic-script compatibility.

Changes

Plugin module cache busting

Layer / File(s) Summary
Module URL generation and reload behavior
static/js/plugin-loader.js, tests/js/plugin_module_rollback.test.js, tests/js/legacy_shim_hits.test.js, tests/js/plugin_loader_script_type.test.js
Module plugins use stable URLs initially and unique /g/<n>/ paths on reloads; classic scripts retain stable URLs. Tests validate URL uniqueness, rollback behavior, relative-import resolution, and updated source anchors.
Generation-prefixed plugin route handling
plugins/__init__.py, tests/test_plugin_src_route.py
Middleware removes /g/<n> before existing plugin routing, while tests cover module files, nested imports, assets, containment, unknown plugins, generic routes, and non-ASCII filenames.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PluginLoader
  participant GenerationMiddleware
  participant PluginRoute
  PluginLoader->>GenerationMiddleware: Request generated screen.js URL
  GenerationMiddleware->>PluginRoute: Rewrite path without generation segment
  PluginRoute-->>PluginLoader: Return plugin resource
  PluginLoader->>PluginLoader: Resolve relative imports under generated path
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: making module plugins re-evaluate on reload.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/879-module-rollback

Comment @coderabbitai help to get the list of available commands.

@byrongamatos
byrongamatos merged commit 7565886 into main Jul 11, 2026
5 checks passed
@byrongamatos
byrongamatos deleted the fix/879-module-rollback branch July 11, 2026 22:20
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.

Plugin rollback silently no-ops for module plugins (?v= reuses an already-evaluated module URL)

1 participant