Skip to content

refactor(app): carve the plugin loader out of app.js into static/js/ (R3a) - #878

Merged
byrongamatos merged 1 commit into
mainfrom
feat/r3-carve-plugin-loader
Jul 11, 2026
Merged

byrongamatos merged 1 commit into
mainfrom
feat/r3-carve-plugin-loader

Conversation

@byrongamatos

@byrongamatos byrongamatos commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

The first carve, and deliberately the riskiest one: app.js is the plugin loader (the R0 host rails). It goes first, while the module graph is still one edge deep and a mistake is easy to see.

static/js/plugin-loader.js (829 lines) — bodies verbatim. app.js 12,217 → 11,439. Core's first static/js/ module, which is exactly the shape constitution §II anticipates.

The closure

Measured with acorn, not regex — brace-matching the comment-stripped source drifted, and this is precisely the kind of file where an off-by-one is a silent disaster.

The block at app.js:11246-12031 turns out to be contiguous and self-contained. It needs two things from the rest of app.js and exports two:

direction symbol handling
export loadPlugins the window contract
export bootstrapPluginsAndUi called at boot
inbound window.showScreen already the public host contract (§II) — called through window, not re-coupled as an import
inbound _populateVizPicker injected via configurePluginLoader()

Why a seam and not an import

plugin-loader must not import app.js — app.js imports it, so that closes a cycle.

I checked whether _populateVizPicker could simply move into the module, which would delete the seam entirely. It drags 9 further symbols (_canRun3D, _autoMatchViz, _showPromotionNag, _ensureVenueVizOption, …) — a whole viz cluster. That's its own carve. So the seam stays.

The seam's default is loud, on purpose

A silent no-op stub is the classic failure for this pattern — the editor hit it twice. Drop the wiring call and the loader keeps working while the viz picker quietly stops refreshing: no test, no boot check, nothing says a word.

So the default hook console.errors instead, which the smoke harness (it treats console errors as failures) will catch.

Bite-tested. Removing configurePluginLoader() from app.js surfaces:

[plugin-loader] host seam not configured — app.js must call configurePluginLoader({ populateVizPicker })

That also proves the seam is exercised on the plugin-startup path, so an unwired hook cannot pass silently. Without this check, my A/B would have been blind to it: the viz picker is empty in the smoke env either way.

no-cycle is live on core's own graph for the first time

eslint.config.js gains static/app.js + static/js/** to the module block — app.js now imports, so parsing it as a script would be a syntax error.

Bite-tested: making plugin-loader import app.js back fails with Dependency cycle detected.

Harnesses — the R3a note said budget one conversion per carve. It was five.

Retargeted: capability_inspector_nav, plugin_hydration_wipe, plugin_loader_script_type, plugin_style_injection, legacy_shim_hits, plus test_plugin_runtime_idempotence.py.

Two things worth knowing for the next carve:

  • legacy_shim_hits was missed by a symbol-name grep because it greps for a code string (script.src = `/api/plugins/${plugin.id}/screen.js` ), not a symbol. Only the failing run found it. It's also split — one test needs the loader, one still needs app.js.
  • test_capability_events' assertions are NEGATIVE (assert token not in source). Carving code out of app.js would have made them vacuous instead of failing. They now span app.js + the loader.

Verification

A/B against origin/main in two browsers — mounted plugin screens, 14 loaded plugin scripts, the 3 module plugins injected as <script type="module">, 37 capability participants, 14 compat shims, window.loadPlugins: IDENTICAL, zero console/page errors on both.

/static/js/plugin-loader.js serves 200. R0 rails intact: stems/src/main.js → 200, conditional GET → 304, script_type passthrough.

pytest 2396 · node 1032/1032 · ESLint 0 errors · tailwind-fresh clean · Codex 0.

Codex caught a real P1 on the first pass

static/js/plugin-loader.js was untracked — I'd never git added it. A checkout of the patch would have served an app.js importing a module that doesn't exist, and a failed static import kills the entire module and every window handler with it. Now tracked. Worth flagging because a green local run says nothing about this.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved plugin loading and UI updates, including support for repeated refreshes during installation and upgrades.
    • Added reliable handling for plugin scripts, styles, navigation, settings, and visualization integrations.
    • Added startup status monitoring with streaming updates and polling fallback.
  • Refactor

    • Plugin management is now handled through a dedicated module, improving maintainability without changing expected behavior.
  • Tests

    • Updated plugin-related tests to validate the new module structure and loading behavior.

…(R3a)

The first carve, and deliberately the riskiest: app.js IS the plugin loader (the
R0 host rails), so it goes first while the module graph is still one edge deep.

static/js/plugin-loader.js (829 lines) — bodies VERBATIM. app.js 12,217 → 11,439.
Core's first `static/js/` module, exactly as constitution II anticipates.

CLOSURE (measured with acorn, not regex — brace-matching stripped source drifted):
the block at app.js:11246-12031 is contiguous and self-contained. It needs only
TWO things from the rest of app.js, and exports only TWO:
  exports: loadPlugins (the window contract), bootstrapPluginsAndUi (boot)
  inbound: window.showScreen — already the public host contract (constitution II),
           so it is called through `window`, not re-coupled as an import
           _populateVizPicker — injected via configurePluginLoader()

WHY A SEAM, NOT AN IMPORT. plugin-loader must not import app.js: app.js imports
it, so that would close a cycle. I checked whether _populateVizPicker could just
move into the module instead (which would delete the seam entirely) — it drags 9
further symbols (_canRun3D, _autoMatchViz, _showPromotionNag, …), i.e. a whole
viz cluster. That is its own carve, so the seam stays.

THE SEAM'S DEFAULT IS LOUD, ON PURPOSE. A no-op stub is the classic silent
failure for this pattern (see the editor's setHostHooks trap, hit twice): drop the
wiring call and the loader keeps working while the viz picker quietly stops
refreshing — no test, no boot check says a word. The default now console.errors,
so the smoke harness catches it. VERIFIED BY BITE TEST: removing
configurePluginLoader() from app.js surfaces
"[plugin-loader] host seam not configured" at boot. The seam IS exercised on the
plugin-startup path, so an unwired hook cannot pass silently.

no-cycle is now LIVE on core's own graph for the first time. eslint.config.js
gains `static/app.js` + `static/js/**` to the module block — app.js now `import`s,
so parsing it as a script would be a syntax error. VERIFIED BY BITE TEST: making
plugin-loader import app.js back fails with "Dependency cycle detected".

HARNESSES (the R3a note said budget one conversion per carve — it was five):
retargeted capability_inspector_nav, plugin_hydration_wipe,
plugin_loader_script_type, plugin_style_injection, legacy_shim_hits (SPLIT — one
test needs the loader, one still needs app.js) + test_plugin_runtime_idempotence.
legacy_shim_hits was missed by a symbol-name grep because it greps for a code
STRING; only the failing run found it. test_capability_events' NEGATIVE asserts
now span app.js + the loader — carving code out of app.js would otherwise make
them vacuous instead of failing.

VERIFIED: A/B against origin/main in two browsers — mounted plugin screens, 14
loaded plugin scripts, the 3 module plugins injected as <script type="module">,
37 capability participants, 14 shims, window.loadPlugins: IDENTICAL, zero
console/page errors on both. /static/js/plugin-loader.js serves 200; R0 rails
intact (src/main.js 200, conditional GET 304, script_type passthrough).
pytest 2396, node 1032/1032, ESLint 0, Codex 0.

Codex preflight caught a REAL [P1] first pass: static/js/plugin-loader.js was
untracked, so a checkout would have served an app.js importing a nonexistent
module — a failed static import kills the whole module and every window handler
with it. Now tracked.

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

📝 Walkthrough

Walkthrough

The plugin loader is extracted from static/app.js into static/js/plugin-loader.js, with host callback wiring, plugin asset/UI reconciliation, startup refresh handling, expanded ES-module lint coverage, and updated source-based regression tests.

Changes

Plugin loader extraction and integration

Layer / File(s) Summary
Module boundary and host wiring
eslint.config.js, static/app.js, static/js/plugin-loader.js
ES-module linting now covers the application and bundle sources. app.js imports the loader and supplies the visualization-picker callback.
Plugin state and UI reconciliation
static/js/plugin-loader.js
Plugin manifests, capability registrations, navigation, settings containers, cached scripts/styles, and hydrated DOM are reconciled during reloads.
Plugin asset and surface hydration
static/js/plugin-loader.js
Ready plugins receive navigation contributions, styles, settings HTML, screen containers, and versioned screen scripts, including module scripts.
Startup refresh orchestration
static/js/plugin-loader.js
Initial loading and subsequent plugin refreshes use startup-status SSE with polling fallback.
Loader-specific regression coverage
tests/js/*, tests/test_plugin_runtime_idempotence.py
Source-based assertions now inspect plugin-loader.js for navigation, hydration, script, style, shim, and idempotence behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant PluginLoader
  participant PluginAPI
  App->>PluginLoader: bootstrapPluginsAndUi()
  PluginLoader->>PluginAPI: Fetch plugin manifests
  PluginAPI-->>PluginLoader: Return plugin list
  PluginLoader->>PluginLoader: Reconcile navigation and plugin assets
  PluginLoader->>PluginAPI: Subscribe to startup status
  PluginAPI-->>PluginLoader: Report plugin registration or error
  PluginLoader->>PluginLoader: Schedule plugin reload
  PluginLoader-->>App: Populate visualization picker
Loading

Possibly related PRs

  • got-feedBack/feedBack#812: Updates module plugin screen script loading, which this change relocates and preserves in the extracted loader.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.00% 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 and concisely describes the main change: extracting the plugin loader from app.js into static/js/.
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 feat/r3-carve-plugin-loader

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@static/js/plugin-loader.js`:
- Around line 683-706: Update the module script URL construction in the plugin
loading flow around the script element setup and loadedScripts tracking so every
reload receives a fresh, monotonic cache-busting token, including when returning
to an older plugin.version. Preserve the existing version bookkeeping while
ensuring type="module" scripts never reuse a previously evaluated module URL.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 54ea3e73-3478-4a1d-b061-aa3d76064063

📥 Commits

Reviewing files that changed from the base of the PR and between 92c86f5 and 6e5fde2.

📒 Files selected for processing (9)
  • eslint.config.js
  • static/app.js
  • static/js/plugin-loader.js
  • tests/js/capability_inspector_nav.test.js
  • tests/js/legacy_shim_hits.test.js
  • tests/js/plugin_hydration_wipe.test.js
  • tests/js/plugin_loader_script_type.test.js
  • tests/js/plugin_style_injection.test.js
  • tests/test_plugin_runtime_idempotence.py

Comment thread static/js/plugin-loader.js
@byrongamatos

Copy link
Copy Markdown
Contributor Author

@coderabbitai Good catch, and the analysis is correct — but it's pre-existing, not introduced here, so I'm deferring it rather than riding it along on a move-only PR.

It's a verbatim move. The URL construction and module injection are byte-identical to origin/main:

line code
origin/main static/app.js 11884 script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
this branch static/js/plugin-loader.js 682 script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;

(same for script.type = 'module', main:11892 → loader:690). It arrived with the R0 module rails in #812; this PR only changed which file it lives in.

The bug is real though. ?v=<plugin.version> is keyed on the version, not on the load, so a downgrade — rolling a plugin back to a version already evaluated this session — reuses a URL that's already in the module map. Modules evaluate once per URL per document, so the script silently doesn't re-run while loadedScripts records that it did. The screen stays stale. Upgrades are fine (new version → new URL); it's specifically the rollback path.

Deferring because the fix is a behaviour change to the plugin-loading path (a monotonic load token changes the URL every reload, which also defeats the ETag/304 live-edit caching the R0 rails deliberately rely on — that interaction needs thinking about, not a one-liner). This PR is a verbatim carve whose entire value is being provably behaviour-neutral; folding a real fix into it would destroy that property and give the on-device pass nothing to compare against.

Filing it as its own issue with your analysis. Thanks — this is a genuinely non-obvious one.

@byrongamatos
byrongamatos merged commit 38772f6 into main Jul 11, 2026
5 checks passed
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@byrongamatos Understood — makes sense to keep this PR provably behaviour-neutral and handle the versioning/downgrade fix separately, especially given the ETag/304 caching interaction that needs its own design thought.

If you'd like, I can open a follow-up GitHub issue capturing this analysis (repo: got-feedBack/feedBack) so it's tracked — just confirm and let me know who to assign it to (defaulting to you if unspecified), and I'll file it referencing this PR/comment as backlinks.


✏️ Learnings added
Learnt from: byrongamatos
URL: https://github.com/got-feedBack/feedBack/pull/878

Timestamp: 2026-07-11T16:18:16.882Z
Learning: In got-feedBack/feedBack, the plugin screen script loading logic (in static/js/plugin-loader.js, previously in static/app.js before PR `#878` moved it verbatim) constructs script URLs as `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}` keyed on plugin.version. This has a known pre-existing bug (introduced in PR `#812`, not `#878`): on a plugin downgrade/rollback to a version already evaluated this session, the URL is reused, and since ES modules evaluate once per URL per document, the script silently doesn't re-run even though `loadedScripts` records it as loaded, leaving the screen stale. Upgrades work fine since they produce new URLs. A fix (e.g., a monotonic load token) would change plugin-loading behavior and interact with ETag/304 caching relied on by the R0 module rails, so it should be addressed in a dedicated issue/PR rather than folded into unrelated refactors.

Learnt from: byrongamatos
URL: https://github.com/got-feedBack/feedBack/pull/878

Timestamp: 2026-07-11T16:18:16.882Z
Learning: In got-feedBack/feedBack, PRs that carve out code verbatim from static/app.js into new modules (e.g., static/js/plugin-loader.js in PR `#878`) are intentionally scoped as move-only/behavior-neutral refactors. Pre-existing bugs found in the moved code (even if real) are deferred to separate follow-up issues rather than fixed inline, to preserve the ability to do a provable behavior-neutral diff/on-device parity check.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

byrongamatos added a commit that referenced this pull request Jul 11, 2026
…der seam (R3a) (#880)

static/js/viz.js (770 lines) — the viz picker, renderer selection, Auto-match,
the WebGL2 probe, the 3D-promotion nag, the notation hints. Bodies VERBATIM.
app.js 11,603 → 10,857.

THE SEAM IS GONE. #878's plugin-loader needed configurePluginLoader({
populateVizPicker }) purely because _populateVizPicker lived in app.js and
importing app.js would have closed a cycle. viz.js is a LEAF — it imports NOTHING
— so plugin-loader now imports _populateVizPicker straight from it. The _host
object, the configure function, its loud-default guard, and the wiring line in
app.js are all deleted. The second carve simplifies the first.

  app.js -> { plugin-loader, viz }
  plugin-loader -> viz
  viz -> (nothing)

NOT A PURE MOVE — one listener block had to be SPLIT. app.js had a single
top-level `if (window.feedBack) { … }` registering four handlers, and only two
were viz. song:loaded / arrangement:changed / song:ready (the mastery slider)
stay in app.js and now call the imported _autoMatchViz / _maybeShowNotationViewHint.
The viz:reverted handler MOVES, because it REASSIGNS _cancelPendingAutoLabel and
an imported binding is read-only — `_cancelPendingAutoLabel = null` would throw if
the listener stayed behind while the state moved.

ORDER CHECKED, NOT ASSUMED: viz.js's song:ready listener now registers BEFORE
app.js's own (imports evaluate first). Safe — _pendingPromotionNag is only ever
set inside _populateVizPicker, which runs at boot/plugin-refresh, never from
inside the other song:ready handler, so the two are independent.

VERIFIED — the listeners are the risk here, so they were DRIVEN, not just booted.
A/B against origin/main in two browsers:
  * viz picker: 6 options (auto|default|venue|drum_highway_3d|keys_highway_3d|
    highway_3d), selected highway_3d, Auto label — IDENTICAL. This alone proves
    plugin-loader's direct import of viz.js works.
  * emit('viz:reverted') -> picker resets to default, localStorage resets to
    default, the warning logs — IDENTICAL. The MOVED listener fires.
  * emit('song:ready') -> mastery slider enables, no throw — IDENTICAL. The SPLIT
    listener still does both halves.
  * plugin screens, module injections, 37 capability participants — IDENTICAL.
  * zero console/page errors on both.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean. no-cycle re-bitten on
the 3-module graph (viz -> plugin-loader fails).

Codex preflight raised a [P2] claiming viz.js's top-level bus guards would be
false because "app.js only creates the event bus later" — FALSE POSITIVE. app.js
does not create the bus; capabilities.js does, from its own <script type="module">
at index.html:122, and module scripts execute in document order, so the bus exists
long before app.js's import graph evaluates. Instrumented the setter: by viz.js's
turn `window.feedBack.on` is already a function, and the viz:reverted listener is
provably attached (firing it resets the picker). The ordering is also enforced by
test_app_shell_loads_capability_registry_before_app_runtime.

Harnesses: 5 tests retargeted to viz.js across legacy_shim_hits, venue_scene_3d,
venue_viz (each SPLIT — their non-viz tests still read app.js).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byrongamatos added a commit that referenced this pull request Jul 11, 2026
static/js/audio-el.js — one exported const. app.js's diff is 5 lines.
This is a HINGE, not a carve: nothing shrinks, but almost everything left in
app.js is blocked behind it.

WHY. `audio` is `document.getElementById('audio')` with 162 references in app.js
and 173 outside any one cluster. Every remaining cluster measured — settings,
app-updates, count-in (208 fns), exit-confirm (212), library-render (220) — lists
`audio` among its inbound symbols, because they all touch playback and playback
reaches for the element directly. A module that needs it cannot import app.js to
get it (that closes a cycle and fails import-x/no-cycle), so today the only way to
carve any of them would be a host seam — the exact thing #878 had to build and
#880 had to tear out.

WHY IT'S SAFE. `audio` is a `const` and is NEVER reassigned anywhere in core, so a
read-only import binding is exactly right and no state container is needed. The
162 call sites are untouched — the binding keeps its name, it is just imported
instead of declared. (Contrast the reassigned scalars — isPlaying, _avOffsetMs —
which CANNOT be shared this way: an imported binding cannot be written to. Those
still need containers, and that is the next problem, not this one.)

TIMING. app.js is <script type="module">, so it evaluates after the HTML is parsed
and its imports evaluate just before its body — the same moment app.js used to run
this exact lookup. If the element had not been in the document, `audio` would be
null and app.js's top-level `audio.addEventListener(...)` calls would throw and
kill the module. They don't.

VERIFIED WITH REAL PLAYBACK, not a boot check. A/B against origin/main in two
browsers: app alive with zero page errors (which is itself the proof the import
resolved), #audio is an AUDIO element, togglePlay/seekBy live, and playSong() on a
real library song sets audio.src and the element reports a duration — IDENTICAL on
both sides.

pytest 2396, node 1038/1038, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byrongamatos added a commit that referenced this pull request Jul 11, 2026
static/js/audio-el.js — one exported const. app.js's diff is 5 lines.
This is a HINGE, not a carve: nothing shrinks, but almost everything left in
app.js is blocked behind it.

WHY. `audio` is `document.getElementById('audio')` with 162 references in app.js
and 173 outside any one cluster. Every remaining cluster measured — settings,
app-updates, count-in (208 fns), exit-confirm (212), library-render (220) — lists
`audio` among its inbound symbols, because they all touch playback and playback
reaches for the element directly. A module that needs it cannot import app.js to
get it (that closes a cycle and fails import-x/no-cycle), so today the only way to
carve any of them would be a host seam — the exact thing #878 had to build and
#880 had to tear out.

WHY IT'S SAFE. `audio` is a `const` and is NEVER reassigned anywhere in core, so a
read-only import binding is exactly right and no state container is needed. The
162 call sites are untouched — the binding keeps its name, it is just imported
instead of declared. (Contrast the reassigned scalars — isPlaying, _avOffsetMs —
which CANNOT be shared this way: an imported binding cannot be written to. Those
still need containers, and that is the next problem, not this one.)

TIMING. app.js is <script type="module">, so it evaluates after the HTML is parsed
and its imports evaluate just before its body — the same moment app.js used to run
this exact lookup. If the element had not been in the document, `audio` would be
null and app.js's top-level `audio.addEventListener(...)` calls would throw and
kill the module. They don't.

VERIFIED WITH REAL PLAYBACK, not a boot check. A/B against origin/main in two
browsers: app alive with zero page errors (which is itself the proof the import
resolved), #audio is an AUDIO element, togglePlay/seekBy live, and playSong() on a
real library song sets audio.src and the element reports a duration — IDENTICAL on
both sides.

pytest 2396, node 1038/1038, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant