refactor(ui): defer every classic script, keep boot() on DOMContentLoaded (R3a) - #872
Conversation
…aded (R3a)
Puts every external `<script>` in the v3 shell into the deferred queue, and
keeps each script's boot() firing at DOMContentLoaded exactly as it does today.
Behaviourally a no-op; it is what makes the ES-module flips safe.
WHY. `type="module"` defers execution to after HTML parse. Classic-`defer` and
module scripts share ONE "execute after parsing" list and run in DOCUMENT ORDER,
but a plain classic script runs DURING parse — ahead of all of them. So the
moment capabilities.js becomes a module while app.js is still plain, app.js runs
FIRST, and its 11 top-level `window.feedBack.on(...)` calls (app.js:6245-6722)
hit a bare `{}` — `_ensureFeedBackEventBus()` (capabilities.js:33), which
attaches .on/.emit/.off, would not have run yet. TypeError, app.js dies
mid-parse. Deferring everything now keeps document order == execution order
through the rest of the migration.
THE CATCH (Codex preflight caught this — a real ordering change). 22 scripts
guard their boot with `if (document.readyState === 'loading')`. A deferred
script runs at readyState 'interactive', so that test is FALSE and the else-branch
fires boot() immediately, at the script's position in document order — instead of
at DOMContentLoaded, after every script has evaluated.
That matters far more than one call site: a scan of the shell's scripts found
**43 forward references** where a script's boot() reads a global that a LATER
script defines (shell.js -> profile.js's window.v3Onboarding, songs.js ->
settings.js's window._confirmDialog, badges.js -> songs.js's
window.displayTuningName, ...). Every one of them resolves today only because
all boots happen at DOMContentLoaded. So the guards now treat 'interactive' as
not-ready (`!== 'complete'`), restoring that exactly.
Codex's specific finding (first-run onboarding silently skipped) did NOT
reproduce — shell.js's boot() awaits /api/profile, and that yield lets the
remaining deferred scripts run first. But the race it described is real, the
guard is silent when it fails (`&& window.v3Onboarding`), and the other 42
forward refs have no such await protecting them. Fixed at the root rather than
at the one site.
VERIFIED. A/B against origin/main on a fresh profile, 13 probes (onboarding
overlay, v3Onboarding/v3Songs/v3Profile/fbNotify/v3Badges/uiPrompt/showScreen,
bus, capabilities.version, createHighway, plugin scripts, mounted screens):
IDENTICAL, zero console/page errors on both. pytest 2396, node 1028/1028,
ESLint 0 errors, Codex 0.
New guard: test_every_external_script_defers_so_document_order_is_execution_order
fails if any external tag is plain classic — verified to fail on a single
reverted tag, so it actually bites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughExternal v3 scripts now use ChangesDeferred initialization ordering
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant HTMLParser
participant DeferredScripts
participant DOMContentLoaded
participant BootstrapModules
HTMLParser->>DeferredScripts: Execute external scripts after parsing
DeferredScripts->>BootstrapModules: Register DOMContentLoaded handlers
DOMContentLoaded->>BootstrapModules: Invoke boot/init/refresh handlers
BootstrapModules->>BootstrapModules: Initialize application features
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/test_plugin_runtime_idempotence.py (1)
101-105: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winSubstring checks can produce false negatives.
'defer' not in tag,'async' not in tag, and'type="module"' not in tagmatch anywhere in the raw tag text, not just attribute names. A future tag with an unrelated attribute containing those substrings (e.g.data-async-load="x") would be misclassified as deferred/async and silently skip the check this test exists to enforce — precisely the regression this test is meant to catch. A word-boundary/attribute-aware regex would be more robust.♻️ Suggested tightening
plain = [ tag for tag in re.findall(r'<script\b[^>]*\bsrc=[^>]*>', source) - if 'defer' not in tag and 'async' not in tag and 'type="module"' not in tag + if not re.search(r'\bdefer\b', tag) + and not re.search(r'\basync\b', tag) + and 'type="module"' not in tag ]🤖 Prompt for 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. In `@tests/test_plugin_runtime_idempotence.py` around lines 101 - 105, Update the script-tag filtering assertion to detect defer, async, and type="module" as actual attribute names/values rather than arbitrary substrings in raw tag text. Use attribute-aware or word-boundary matching so unrelated attributes such as data-async-load do not bypass the plain-script check, while preserving the existing external-script detection.
🤖 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.
Nitpick comments:
In `@tests/test_plugin_runtime_idempotence.py`:
- Around line 101-105: Update the script-tag filtering assertion to detect
defer, async, and type="module" as actual attribute names/values rather than
arbitrary substrings in raw tag text. Use attribute-aware or word-boundary
matching so unrelated attributes such as data-async-load do not bypass the
plain-script check, while preserving the existing external-script detection.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 12e6a43c-b1a9-4eba-b457-f255f330dee3
📒 Files selected for processing (24)
static/app.jsstatic/audio-mixer.jsstatic/capabilities/interface-scale.jsstatic/v3/badges.jsstatic/v3/dashboard.jsstatic/v3/feedbarcade.jsstatic/v3/index.htmlstatic/v3/interface-size-nudge.jsstatic/v3/interface-size-ui.jsstatic/v3/live-guitar-tone-source.jsstatic/v3/live-performance-hud.jsstatic/v3/match-review.jsstatic/v3/player-chrome.jsstatic/v3/playlists.jsstatic/v3/plugins-page.jsstatic/v3/profile.jsstatic/v3/progress.jsstatic/v3/progression-core.jsstatic/v3/settings.jsstatic/v3/shell.jsstatic/v3/shop.jsstatic/v3/venue-mood-fx.jsstatic/v3/venue-scene-3d.jstests/test_plugin_runtime_idempotence.py
The 12 capability <script> tags become type="module". No JS changes — the capability scripts already self-register on the window.feedBack bus, version-negotiate (`capabilities.version !== 1` → bail), and self-guard for idempotency. They never import or call app.js; it is pure pub/sub. Verified they export nothing by name: no top-level declaration in capabilities.js or capabilities/*.js is read by any other script, so losing global scope costs nothing. This is the first REAL exercise of the ordering fix from #872. A module defers to after HTML parse, so the capabilities now execute AFTER the document is parsed — while app.js still calls `window.feedBack.on(...)` at its top level. That only works because #872 put every classic script into the same deferred queue, where document order IS execution order: capabilities.js (line 122) still runs before app.js (line 1237). Had app.js stayed a plain classic script it would have run during parse, hit a bare `{}`, and died on `.on is not a function`. A/B against origin/main, 11 probes — capabilities.version, registered participants (37), compatibility shims (14), the bus, workingTuning, theme, setViz/showScreen/playSong, mounted plugin screens: IDENTICAL, zero console/page errors on both. 12 module tags served and executed; pytest 2396, node 1032/1032, ESLint 0, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One attribute. #871/#872/#874/#875 exist to make this line safe. app.js's 385 top-level `function` declarations stop being implicit `window` properties: 87 stay reachable via the explicit contract (#874's Object.assign block + the 47 pre-existing `window.X = X` assignments), and 298 become module-private. Verified NO unexposed name is read from outside app.js. Strict mode (modules are always strict) checked ahead of the flip: app.js parses clean as `sourceType: module` (no octal, dup params, `with`), and has no implicit globals, no `eval`/`new Function`, no top-level `this`. `registerShortcut` is called bare at 15 top-level sites but is assigned at `window.registerShortcut` (app.js:10387) before its first call (10648), and a bare identifier in a module still resolves through the global object — verified `typeof window.registerShortcut === 'function'` in the browser. HARD GATE — app.js IS the plugin loader: - /api/plugins script_type passthrough: editor/stems/studio = "module" - /api/plugins/stems/src/main.js -> 200; conditional GET -> 304 (live-edit ETag) - deep graph: stems/src/transport.js, editor/src/state.js -> 200 - window.loadPlugins present; 5 plugin screens mount; the 3 migrated plugins injected as <script type="module"> - 37 capability participants, 14 compatibility shims, bus + capabilities v1 Every one of the shell's 336 inline handlers resolves on window under module scope, and the A-Z rail / pagination execute 6/6 with no ReferenceError. A/B against origin/main: the ONLY unresolvable handler is `editorToggleStemMixer`, which is equally broken on main (a dead handler in the editor plugin — not defined anywhere in its source; pre-existing, flagged separately). Codex preflight raised a [P1] claiming restartCurrentSong / requestExitSong / editRegionInEditor / returnToEditorFromHighway would ReferenceError — FALSE POSITIVE. It scanned only #874's new Object.assign block and missed app.js's 47 scattered `window.X = X` assignments; all four are at app.js:7086/7204/8492/8511 and all four resolve as `function` in the browser with app.js loaded as a module. pytest 2396, node 1032/1032, ESLint 0 errors, tailwind-fresh clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Step 1 of the core-frontend ES-module migration (R3a), on top of #871. Puts every external
<script>in the v3 shell into the deferred queue while keeping each script'sboot()firing atDOMContentLoadedexactly as it does today. Behaviourally a no-op — it exists to make the module flips (PR-3, PR-4) safe.Why
type="module"defers execution to after HTML parse. Classic-deferand module scripts share one "execute after parsing" list and run in document order — but a plain classic script runs during parse, ahead of all of them.So the moment
capabilities.jsbecomes a module whileapp.jsis still plain, app.js runs first, and its 11 top-levelwindow.feedBack.on(...)calls (app.js:6245-6722) hit a bare{}._ensureFeedBackEventBus()(capabilities.js:33) — which attaches.on/.emit/.off— hasn't run yet.TypeError, app.js dies mid-parse.The bus is order-tolerant for properties (
_ensureFeedBackEventBuscopies keys off a bare{}and replaces it). It is not tolerant of.on()being called before it exists. Deferring everything keeps document order == execution order for the rest of the migration.The catch — a real ordering change, caught by Codex preflight
22 scripts guard their boot with
if (document.readyState === 'loading'). A deferred script runs at readyState'interactive', so that test is false and the else-branch firesboot()immediately, at the script's position in document order — instead of atDOMContentLoaded, after every script has evaluated.That matters much more than the one call site Codex named. A scan of the shell found 43 forward references where a script's
boot()reads a global a later script defines:v3/shell.jswindow.v3Onboardingv3/profile.jsv3/songs.jswindow._confirmDialogv3/settings.jsv3/badges.jswindow.displayTuningNamev3/songs.jsv3/playlists.jswindow.uiPromptv3/image-picker.jsapp.jswindow.fbNotifyv3/notifications.jsEvery one resolves today only because all boots happen at
DOMContentLoaded. So the guards now treat'interactive'as not-ready (!== 'complete'), restoring that exactly. Each site carries a one-line note so it doesn't get "fixed" back.On Codex's specific finding (first-run onboarding silently skipped): it did not reproduce.
shell.js'sboot()awaits/api/profile, and that yield lets the remaining deferred scripts run first — I traced it on both branches to confirm. But the race it describes is real, the guard fails silently (&& window.v3Onboarding— no error, onboarding just never shows), and the other 42 forward refs have noawaitprotecting them. Fixed at the root rather than at the one site.Verification
A/B against
origin/mainon a fresh profile (onboarded: false), 13 probes — onboarding overlay,v3Onboarding/v3Songs/v3Profile/fbNotify/v3Badges/uiPrompt/showScreen, the bus,capabilities.version,createHighway, loaded plugin scripts, mounted plugin screens:IDENTICAL on every probe. Zero console/page errors on both.
test:js1028/1028 · ESLint 0 errors · tailwind-fresh clean · Codex preflight 0.<script type="module">.New guard:
test_every_external_script_defers_so_document_order_is_execution_orderfails if any external tag is plain classic. Verified it actually bites — reverting a single tag fails it withexternal scripts that would jump the deferred queue: ['<script src="/static/app.js">']. (A guard that passes both ways guards nothing.)Note on the 4 inline
<script>blocksLeft parse-time on purpose.
:55(ss-follower) and:69(interface-scale pre-paint, explicitly "before any stylesheet paints") must run before paint; the other two are self-contained. None reads a global from a deferred script — checked.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests