refactor(ui): defer every classic script, keep boot() on DOMContentLoaded (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>
This commit is contained in:
byrongamatos 2026-07-11 16:49:17 +02:00
parent 9d0bf95716
commit f69d1f73b1
24 changed files with 165 additions and 78 deletions

View File

@ -6638,7 +6638,9 @@ window.feedBack.on('screen:changed', (ev) => {
if (id === 'player') _hideResumePill();
else _maybeShowResumePill();
});
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded',
() => { try { _maybeShowResumePill(); } catch (_) {} }, { once: true });
} else {

View File

@ -519,7 +519,9 @@ window.feedBack.audio = Object.assign(window.feedBack.audio || {}, {
readSongVolume: _readSongVolume,
});
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();

View File

@ -111,7 +111,9 @@
// Announce once after the document parses, so any listener wired during page
// load can sync without special-casing (consumers may also just call get()).
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', announce, { once: true });
} else {
announce();

View File

@ -592,6 +592,8 @@
sm.on('working-tuning-changed', () => renderInstrument());
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();

View File

@ -271,6 +271,8 @@
sm.on('v3:profile-updated', () => render());
}
function boot() { render(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();

View File

@ -273,7 +273,9 @@
// the stage observer attaches.
window.addEventListener('feedBack-minigames-ready', () => { ensureStageObserver(); refresh(); });
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();

View File

@ -99,23 +99,39 @@
<link rel="stylesheet" href="/static/tour-engine.css">
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
<link rel="stylesheet" href="/static/v3/v3.css">
<!-- EVERY external script below is `defer`. Do not add a plain one.
`defer` and `type="module"` scripts share a single "execute after
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
DURING parse, ahead of all of them. So one plain tag would jump the
queue — and once the capabilities become modules (they defer), a
still-plain app.js would run BEFORE the bus exists and die on its
top-level `window.feedBack.on(...)` calls. Keeping every tag deferred
is what preserves this file's order through the ES-module migration.
Enforced by test_every_external_script_defers_so_document_order_is_execution_order.
The scripts themselves boot on DOMContentLoaded, which fires only after
all of the above have evaluated — that is what lets a script's boot()
use a global another script defines further down this list (there are
~43 such forward references). Their readyState guards therefore treat
'interactive' as not-ready; see the note at each one. -->
<!-- Diagnostics console capture must wrap console.* before any other
script logs anything; load it as early as possible. See
docs/diagnostics-bundle-spec.md (feedBack#166). -->
<script src="/static/diagnostics.js"></script>
<script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.js"></script>
<script src="/static/capabilities/working-tuning.js"></script>
<script src="/static/capabilities/audio-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script>
<script defer src="/static/diagnostics.js"></script>
<script defer src="/static/capabilities.js"></script>
<script defer src="/static/capabilities/library.js"></script>
<script defer src="/static/capabilities/tuning.js"></script>
<script defer src="/static/capabilities/working-tuning.js"></script>
<script defer src="/static/capabilities/audio-session.js"></script>
<script defer src="/static/capabilities/audio-effects.js"></script>
<script defer src="/static/capabilities/playback.js"></script>
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
<script src="/static/capabilities/library-card-actions.js"></script>
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
<script src="/static/capabilities/midi-input.js"></script>
<script src="/static/capabilities/interface-scale.js"></script>
<script defer src="/static/capabilities/library-card-actions.js"></script>
<script defer src="/static/capabilities/visualization.js"></script>
<script defer src="/static/capabilities/note-detection.js"></script>
<script defer src="/static/capabilities/midi-input.js"></script>
<script defer src="/static/capabilities/interface-scale.js"></script>
</head>
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
@ -1225,64 +1241,64 @@
</main>
<!-- /#v3-main -->
<script src="/static/highway.js"></script>
<script src="/static/vendor/lottie.min.js"></script>
<script src="/static/lottie-api.js"></script>
<script src="/static/app.js"></script>
<script src="/static/audio-mixer.js"></script>
<script src="/static/vendor/shepherd.min.js"></script>
<script src="/static/tour-engine.js"></script>
<script defer src="/static/highway.js"></script>
<script defer src="/static/vendor/lottie.min.js"></script>
<script defer src="/static/lottie-api.js"></script>
<script defer src="/static/app.js"></script>
<script defer src="/static/audio-mixer.js"></script>
<script defer src="/static/vendor/shepherd.min.js"></script>
<script defer src="/static/tour-engine.js"></script>
<!-- fee[dB]ack v0.3.0 shell: brand helper, then the shell (sidebar/topbar/
routing). Loaded after app.js/audio-mixer so window.showScreen and
window.feedBack(.audio) exist; dashboard.js is filled in prompt 13. -->
<script src="/static/v3/brand.js"></script>
<script src="/static/v3/shell.js"></script>
<script defer src="/static/v3/brand.js"></script>
<script defer src="/static/v3/shell.js"></script>
<!-- Progression (spec 010): theme-core before profile.js so the equipped
theme/avatar frame apply with the first badge render; progression-core
registers the `progression` capability owner + window.v3Progression. -->
<script src="/static/v3/theme-core.js"></script>
<script src="/static/v3/progression-core.js"></script>
<script src="/static/v3/notifications.js"></script>
<script src="/static/v3/profile.js"></script>
<script src="/static/v3/progress.js"></script>
<script src="/static/v3/shop.js"></script>
<script src="/static/v3/tuner-core.js"></script>
<script src="/static/v3/badges.js"></script>
<script src="/static/v3/stats-recorder.js"></script>
<script src="/static/v3/live-performance-hud.js"></script>
<script src="/static/v3/scoreboard-pref.js"></script>
<script src="/static/v3/venue-viz.js"></script>
<script src="/static/v3/venue-instrument-pov.js"></script>
<script defer src="/static/v3/theme-core.js"></script>
<script defer src="/static/v3/progression-core.js"></script>
<script defer src="/static/v3/notifications.js"></script>
<script defer src="/static/v3/profile.js"></script>
<script defer src="/static/v3/progress.js"></script>
<script defer src="/static/v3/shop.js"></script>
<script defer src="/static/v3/tuner-core.js"></script>
<script defer src="/static/v3/badges.js"></script>
<script defer src="/static/v3/stats-recorder.js"></script>
<script defer src="/static/v3/live-performance-hud.js"></script>
<script defer src="/static/v3/scoreboard-pref.js"></script>
<script defer src="/static/v3/venue-viz.js"></script>
<script defer src="/static/v3/venue-instrument-pov.js"></script>
<!-- venue-mood-fx must load before venue-scene-3d: the scene bridge reads
window.v3VenueMoodFx.getMotion() synchronously at boot when the saved
viz is 'venue'; loading it after falls back to 'subtle' and ignores a
saved 'off'/'full' motion preference on first paint. -->
<script src="/static/v3/venue-mood-fx.js"></script>
<script src="/static/v3/venue-scene-3d.js"></script>
<script src="/static/v3/playlists.js"></script>
<script src="/static/v3/audio-routing.js"></script>
<script src="/static/v3/live-guitar-tone-source.js"></script>
<script src="/static/v3/pedal-cables.js"></script>
<script src="/static/v3/plugins-page.js"></script>
<script src="/static/v3/card-actions-core.js"></script>
<script defer src="/static/v3/venue-mood-fx.js"></script>
<script defer src="/static/v3/venue-scene-3d.js"></script>
<script defer src="/static/v3/playlists.js"></script>
<script defer src="/static/v3/audio-routing.js"></script>
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
<script defer src="/static/v3/pedal-cables.js"></script>
<script defer src="/static/v3/plugins-page.js"></script>
<script defer src="/static/v3/card-actions-core.js"></script>
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
on build, so the module must already be registered. -->
<script src="/static/v3/match-review.js"></script>
<script defer src="/static/v3/match-review.js"></script>
<!-- Before songs.js: the drawer art click + card ⋮ "Change cover…" open
the cover picker (window.__fbOpenImagePicker). -->
<script src="/static/v3/image-picker.js"></script>
<script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script>
<script src="/static/v3/settings.js"></script>
<script src="/static/v3/interface-size-ui.js"></script>
<script defer src="/static/v3/image-picker.js"></script>
<script defer src="/static/v3/songs.js"></script>
<script defer src="/static/v3/lessons.js"></script>
<script defer src="/static/v3/dashboard.js"></script>
<script defer src="/static/v3/settings.js"></script>
<script defer src="/static/v3/interface-size-ui.js"></script>
<!-- First-run home tour: spotlights the home cards via the shared tour
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
(triggered from profile.js finish()); replayable from the "?" menu. -->
<script src="/static/v3/onboarding-tour.js"></script>
<script src="/static/v3/interface-size-nudge.js"></script>
<script src="/static/v3/feedbarcade.js"></script>
<script src="/static/v3/player-chrome.js"></script>
<script defer src="/static/v3/onboarding-tour.js"></script>
<script defer src="/static/v3/interface-size-nudge.js"></script>
<script defer src="/static/v3/feedbarcade.js"></script>
<script defer src="/static/v3/player-chrome.js"></script>
<script>
// Navbar scroll effect
window.addEventListener('scroll', () => {

View File

@ -71,7 +71,9 @@
setTimeout(maybeNudge, 4000);
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();

View File

@ -45,7 +45,9 @@
// Settings markup is static, but re-sync when settings.js signals it wired.
document.addEventListener('v3:settings-rendered', function () { sync(); });
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', function () { sync(); }, { once: true });
} else {
sync();

View File

@ -94,7 +94,9 @@
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();

View File

@ -303,7 +303,9 @@
const sm = root && root.feedBack;
if (sm) bindRuntime(sm);
};
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();

View File

@ -913,7 +913,9 @@
});
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', () => {
wireSettingsCard();
wireScreenTeardown();

View File

@ -362,6 +362,8 @@
syncActivation();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', init);
else init();
})();

View File

@ -440,6 +440,8 @@
});
}
function boot() { renderPlaylists(); renderSaved(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();

View File

@ -579,6 +579,8 @@
}, { passive: true });
function boot() { render(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();

View File

@ -792,7 +792,9 @@
});
}
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();

View File

@ -320,7 +320,9 @@
});
}
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();

View File

@ -241,7 +241,9 @@
};
_registerOwner();
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', () => { refresh(); }, { once: true });
} else {
refresh();

View File

@ -200,7 +200,9 @@
});
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();

View File

@ -399,7 +399,9 @@
setTimeout(refreshHomeTitle, 700);
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();

View File

@ -203,7 +203,9 @@
});
}
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();

View File

@ -511,7 +511,9 @@
const sm = root && root.feedBack;
if (sm) bindRuntime(sm);
};
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();

View File

@ -242,7 +242,9 @@
if (typeof document !== 'undefined') {
const boot = () => bindRuntime();
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();

View File

@ -1,3 +1,4 @@
import re
from pathlib import Path
import pytest
@ -71,13 +72,39 @@ def test_capability_visualizer_waits_for_registry_instead_of_hard_error():
def test_app_shell_loads_capability_registry_before_app_runtime():
source = (ROOT / "static" / "v3" / "index.html").read_text(encoding="utf-8")
assert '<script src="/static/capabilities.js"></script>' in source
assert '<script src="/static/capabilities/library.js"></script>' in source
assert re.search(r'<script[^>]+src="/static/capabilities\.js"', source)
assert re.search(r'<script[^>]+src="/static/capabilities/library\.js"', source)
assert source.index('/static/diagnostics.js') < source.index('/static/capabilities.js')
assert source.index('/static/capabilities.js') < source.index('/static/capabilities/library.js')
assert source.index('/static/capabilities/library.js') < source.index('/static/app.js')
def test_every_external_script_defers_so_document_order_is_execution_order():
"""The shell's scripts must all execute in document order.
`capabilities.js` builds the `window.feedBack` bus and must run before
`app.js`, which calls `window.feedBack.on(...)` at top level. Today document
order gives that for free, because every script is a parse-time classic one.
That guarantee survives the ES-module migration ONLY while no script is a
*plain* classic script: `defer` and `type="module"` scripts share one
"execute after parsing" list and run in document order, but a plain classic
script runs DURING parse ahead of every deferred one. So the moment
capabilities.js becomes a module while app.js is still plain, app.js runs
first and `window.feedBack.on` is undefined.
Pinning "no plain external scripts" is what keeps that from silently
regressing as tags flip to type="module" one at a time.
"""
source = (ROOT / "static" / "v3" / "index.html").read_text(encoding="utf-8")
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
]
assert not plain, f"external scripts that would jump the deferred queue: {plain}"
def test_capability_registry_exposes_claim_dispatch_and_ready_contracts():
source = (ROOT / "static" / "capabilities.js").read_text(encoding="utf-8")