fix(nav): nobody may monkey-patch window.showScreen — add screen:changing, make the shell listen (#924) - #925
Merged
Merged
Conversation
… inside showScreen
Testers: "randomly, when moving to the library from another menu option, the library shows the
old interface — never when a song ends."
━━━ WHAT WAS ACTUALLY HAPPENING ━━━
#home is the PRE-V3 library screen. The v3 shell replaced it with #v3-songs, and the mapping DID
exist — but only inside WRAPPERS on window.showScreen, and only for callers that go through
`window`. THREE independent parties monkey-patch it, each capturing whatever happens to be there
at the time:
app.js publishes the raw function
-> shell.js wraps it, adding the home -> v3-songs mapping
-> the stems plugin wraps it AGAIN (src/main.js:1029), capturing the current value
Plugins load ASYNCHRONOUSLY. The chain links up in whatever order the race settles, and any
capture taken before shell.js installs — or any re-assignment after it — silently drops the
mapping. Hence "randomly".
AND THE INTERNAL CALLERS NEVER TOUCHED window.showScreen AT ALL. closeCurrentSong and the
Esc-from-settings shortcut call the IMPORTED showScreen, which no wrapper ever sees. Reproduced
in a browser: the unwrapped function with 'home' lands on the dead legacy screen EVERY time.
"Never when a song ends" is the tell, and it is what identified the mechanism: closeCurrentSong
resolves its target through _resolvePlayerOrigin(), which ALREADY applies this mapping. That one
path was fine — which is exactly why the bug looked random rather than total.
PRE-EXISTING, not a regression from the module carve: the onclick="showScreen('home')" links and
the wrapper-only mapping both date to 2026-06-22.
━━━ THE FIX ━━━
The guard lives inside showScreen now: ONE place, in the function every caller routes through,
instead of a chain of monkey-patches that must each remember. Wrapper order stops mattering, and
the module-internal callers are covered for the first time.
Verified in a browser: the raw, unwrapped showScreen('home') — which reproduced as #home — now
lands on #v3-songs, and cannot be undone by any wrapper order.
━━━ AND A [P1] I INTRODUCED, WHICH CODEX CAUGHT ━━━
My first cut mapped BOTH 'home' and 'v3-home', copied straight from _resolvePlayerOrigin.
That is correct THERE and wrong HERE. _resolvePlayerOrigin computes where to RETURN TO after a
song, and landing on the Songs list from the dashboard is the right behaviour. But #v3-home is
the v3 DASHBOARD — a real screen that the shell's Home nav, the onboarding tour and the dashboard
re-render listener all target. Redirecting it would have made Home unreachable.
A LEGACY ALIAS IS NOT THE SAME THING AS A RETURN TARGET. Only 'home' is mapped now, and a test
pins that: re-adding 'v3-home' to the guard fails it.
4 tests, bite-tested both ways.
node 1049, pytest 2425, ESLint 0, Codex 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ging, make the shell listen (#924) window.showScreen was wrapped by THREE independent parties, each capturing whatever happened to be there at the time: app.js publishes the raw function -> static/v3/shell.js wrapped it (to call syncActive, and to map home -> v3-songs) -> the stems plugin wrapped it AGAIN (to tear down on leaving the player) Plugins load ASYNCHRONOUSLY, so the chain linked up in whatever order the race settled. A capture taken before shell.js installed silently dropped the mapping it carried — and the library opened on the dead legacy #home screen. Testers saw that as "randomly, the library shows the old interface" (#923). #923 fixed the symptom by moving the mapping inside showScreen. This removes the CAUSE: neither wrapper ever needed to be one. ━━━ TWO EVENTS, AND THE DISTINCTION IS THE WHOLE POINT ━━━ screen:changing emitted BEFORE anything happens. "I am leaving `from`." Teardown/cancel here. screen:changed emitted after the DOM and data settle. "I am on `id`." Now carries `from`. screen:changing is new, and it exists because Codex caught me collapsing the two. The stems plugin tore down its audio graph BEFORE showScreen did anything; screen:changed fires at the very END, after core awaits library and provider loads — so moving the plugin onto it would have delayed teardown behind a slow fetch, or skipped it entirely if that fetch threw, and stems would keep playing on a non-player screen. A test pins the ordering: screen:changing must precede the first await. shell.js is a plain screen:changed listener now, like app.js, audio-mixer.js and tour-engine.js already were. window.showScreen is an unwrapped function again, and tests/js/ no_showscreen_monkeypatch.test.js fails CI if anything in static/ ever assigns to it again — so the hazard is structurally impossible rather than merely avoided. ━━━ AND A FALLBACK THAT COULD NEVER FIRE ━━━ My retry-if-the-bus-is-late path listened for `slopsmith:capabilities:ready`. Core dispatches `feedBack:capabilities:ready` (capabilities.js:1536) — the slopsmith: name is the PRE-DMCA event and nothing has emitted it since the rename. Codex caught it. A guard that cannot fire is worse than no guard: it reads as protection and is decoration. (The same dead-event bug turned out to be sitting in THREE of the stems plugin's fallbacks, where it has silently disabled its lifecycle wiring whenever the bus was late. Fixed in feedback-plugin-stems#38.) VERIFIED. A/B against origin/main: the nav highlight and topbar title follow IDENTICALLY with shell.js as a listener; screen:changing -> screen:changed fire in order with the right {id, from}; window.showScreen is unwrapped; and showScreen('home') still lands on v3-songs. node 1053, pytest 2425, ESLint 0, Codex 0. Closes #924 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthrough
ChangesScreen navigation
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant showScreen
participant feedBack
participant v3Shell
showScreen->>feedBack: Emit screen:changing with id and from
showScreen->>feedBack: Emit screen:changed with id and from
feedBack->>v3Shell: Deliver screen:changed
v3Shell->>v3Shell: Invoke syncActive(id)
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #924. Stacked on #923.
window.showScreenwas wrapped by three independent parties, each capturing whatever happened to be there at the time:Plugins load asynchronously, so the chain linked up in whatever order the race settled. A capture taken before shell.js installed silently dropped the mapping it carried — and the library opened on the dead legacy
#homescreen. Testers saw that as "randomly, the library shows the old interface" (#923).#923 fixed the symptom. This removes the cause: neither wrapper ever needed to be one.
Two events, and the distinction is the whole point
screen:changingfrom." Teardown/cancel here.screen:changedid." Now also carriesfrom.screen:changingis new, and it exists because Codex caught me collapsing the two.The stems plugin tore down its audio graph before
showScreendid anything.screen:changedfires at the very end — after core awaits library and provider loads — so moving the plugin onto it would have delayed teardown behind a slow fetch, or skipped it entirely if that fetch threw, and stems would keep playing on a non-player screen.A test pins the ordering:
screen:changingmust precede the firstawait.shell.jsis a plainscreen:changedlistener now — likeapp.js,audio-mixer.jsandtour-engine.jsalready were.window.showScreenis an unwrapped function again, andtests/js/no_showscreen_monkeypatch.test.jsfails CI if anything instatic/ever assigns to it again, so the hazard is structurally impossible rather than merely avoided.And a fallback that could never fire
My retry-if-the-bus-is-late path listened for
slopsmith:capabilities:ready. Core dispatchesfeedBack:capabilities:ready(capabilities.js:1536) — theslopsmith:name is the pre-DMCA event, and nothing has emitted it since the rename. Codex caught it.A guard that cannot fire is worse than no guard: it reads as protection and is decoration.
The same dead-event bug turned out to be sitting in three of the stems plugin's fallbacks, where it has silently disabled its lifecycle wiring whenever the bus was late. Fixed in feedback-plugin-stems#38.
Verification
A/B against
origin/main:screen:changing → screen:changedfire in order, with the right{id, from}window.showScreenis unwrappedshowScreen('home')still lands onv3-songsnode 1053 · pytest 2425 · ESLint 0 · Codex 0.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests