From 77628a5d7d5d48ee75619ea4d5cabae594a5c04d Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:51:45 +0000 Subject: [PATCH 1/5] Document and harden the Difficulty Ladder peer contract (#13) The difficulty glasses are Section Map's only cross-plugin dependency, and neither the README nor the manifest said so. Pin the contract in screen.js, CLAUDE.md and the README, and harden the handshake itself. - Retry the capability check on every 200ms poller tick instead of deciding once per player-screen mount. The old one-shot check relied on the Host loading plugins alphabetically; core's session.js says plugins load ASYNCHRONOUSLY "in whatever order the race settles", so a late or reloaded Difficulty Ladder left the feature unsubscribed for the rest of the session. There is no readiness event to subscribe to: the peer sets its marker at top-level script execution and declares no capability domain. - Require window._ddCapabilities.sectionDifficulty === true rather than the mere existence of that global. - Normalize payloads before rendering: drop entries whose fillPercentage is not a finite number (the peer's arithmetic can produce NaN, which sailed through the old typeof === 'number' check and reached the style attribute as height:NaN%), and ignore a payload container that isn't an object so the last good state survives. A well-formed map with nothing renderable still clears the glasses. - Allowlist glassSize against SM_GLASS_SIZES: it is the one payload field that reaches markup unescaped, as data-size. - Publish Difficulty Ladder v0.12.0 as the payload floor. The issue proposed v0.9.13, but that version was never released; the peer's own README and CHANGELOG call v0.12.0 the floor (its 0.12.0 entry is where difficulty_ladder.sections.v2 is documented; the event itself shipped in v0.2.0). Document it as a floor rather than a check: the capability marker carries no version and predates v0.2.0, so a below-floor peer still renders, it just computes fillPercentage with the pre-0.9.11 formula and can disagree with Difficulty Ladder's own HUD. - Declare the optional peer in plugin.json's capabilities block (roles, observes, compatibility degrade-noop), which is the manifest surface the spec defines for cross-plugin participation: there is no dependency key. - Add 12 peer-contract tests covering an absent provider, a foreign _ddCapabilities global, a late-loading provider, v0.12.0-shaped and current (v3-advertising) payloads, malformed events, unreadable entries, glassSize markup, show/hide re-subscription, and the no-unsubscribe-handle retry path. v3 (difficulty:sections-updated-v3) is deliberately left unconsumed; #14 owns that rework. --- CLAUDE.md | 65 ++++++-- README.md | 36 +++++ plugin.json | 16 +- screen.js | 226 +++++++++++++++++++-------- tests/screen.test.js | 355 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 625 insertions(+), 73 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 328f460..81150c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,15 +30,62 @@ reading state straight off the Host's `window.highway` object. `difficulty_ladder`'s emitted event instead (`_smUpdateDifficultyFills` / `_smGetSectionDifficulty` just render whatever `fillPercentage` / `glassSize` the event's payload carries per section). This plugin still - works standalone with no ladder plugin installed — `_smIsDynamicDifficultyAvailable()` - gates the subscription on `window._ddCapabilities`, absent means no - glasses are shown, not an error — but when a ladder plugin *is* installed, - this is the API, not a coincidence of both sides reading the same Host - state. See `difficulty_ladder`'s `INTEGRATION.md` for the full contract - (fill formula, fallback/timing behavior). Load order matters: plugins load - alphabetically, so `difficulty_ladder` (< `section_map`) sets - `window._ddCapabilities` before this plugin's one-time availability check - runs. + works standalone with no ladder plugin installed — the glasses are its only + dependent feature, and `_smIsDynamicDifficultyAvailable()` gates the + subscription, so absent means no glasses rather than an error — but when a + ladder plugin *is* installed, this is the API, not a coincidence of both + sides reading the same Host state. See `difficulty_ladder`'s `INTEGRATION.md` + for the full contract (fill formula, fallback/timing behavior). The short + version is pinned as a comment block above `_smIsDynamicDifficultyAvailable()` + in `screen.js`; keep the two in sync if either changes. +- **The peer handshake is re-checked, not decided once (issue #13).** + `_smEnsureDifficultySubscription()` runs from `_smStartRealtimeHooks()` *and* + from every `_smUpdate()` tick, so a Difficulty Ladder that loads or reloads + after this plugin is still picked up mid-session. Don't reintroduce a + one-shot check: the old one leaned on alphabetical plugin loading + (`difficulty_ladder` < `section_map`), which core does not actually promise — + `feedBack/static/js/session.js` is explicit that "plugins load ASYNCHRONOUSLY, + so the chain links up in whatever order the race settles". There is no + readiness event to subscribe to either — the peer sets its marker at top-level + script execution and declares no capability domain — so the 200 ms tick is the + retry. `_smIsDynamicDifficultyAvailable()` requires + `window._ddCapabilities.sectionDifficulty === true`, not merely that some + `_ddCapabilities` global exists, so a foreign user of that global can't unlock + a subscription we can't render. That function must stay reachable-before-bus: + it returns early when `feedBack.on` isn't a function yet, and once it has + registered a handler `_smDifficultySubscribed` stays true even if the bus + returned no unsubscribe handle — otherwise the tick would re-register one + handler per 200ms. +- **The peer compatibility floor is Difficulty Ladder v0.12.0, not v0.2.0.** + v0.2.0 is when the *event* first shipped; v0.12.0 is where that plugin's + CHANGELOG documents the `difficulty_ladder.sections.v2` payload shape this + plugin renders. Issue #13 proposed publishing `v0.9.13` as the floor, but that + version was never released — the peer's own README and CHANGELOG call + `v0.12.0` the real floor, so that's the number this repo publishes. The floor + is documentation, not enforcement, and shouldn't be restated as a check: the + marker carries no version and predates v0.2.0, so a below-floor peer passes it + and still paints glasses. What actually changed below the floor is the + arithmetic behind `fillPercentage` (v0.9.11 aligned it with the peer's own + HUD), so an old peer's glasses can *disagree* with Difficulty Ladder's HUD + rather than fail to render. Keep the README table and the `screen.js` comment + block on the same number. +- **Payloads are normalized, not trusted (`_smNormalizeSectionDifficulties`).** + Entries without a finite numeric `fillPercentage` are dropped, which is what + keeps a malformed payload from painting a misleading near-empty glass — and + what stops the peer's NaN arithmetic (a NaN `mastery` or phrase difficulty) + from reaching the style attribute as `height:NaN%`. A container that isn't an + object is ignored entirely so the last good state survives. `null` return + means "ignore the event", `{}` means "clear the glasses" — don't collapse those + two. `glassSize` is the one payload field that reaches markup unescaped (as + `data-size`), so `_smGlassSize()` resolves it against `SM_GLASS_SIZES` before + either render path interpolates it. +- **v3 of the peer contract is deliberately not consumed.** + `difficulty:sections-updated-v3` is a render-neutral payload that + `difficulty_ladder` emits *alongside* v2. Consuming it means reworking the + glass rendering (v3 carries the facts, not the metaphor) — that's this repo's + issue #14, and the peer's own transition policy exists so an un-upgraded + Section Map keeps working on the frozen v2 event meanwhile. Don't sniff + `schema` to "upgrade" this plugin to v3 in passing. - **Seeking must go through the Host's canonical funnel** (`window.feedBack.seek` / `window.slopsmith.seek`, wrapped by `_smSeek`), not by poking `audio.currentTime` directly — see the comment on `_smSeek` diff --git a/README.md b/README.md index d8b95bb..64eb78b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A plugin for [feedBack](https://github.com/got-feedBack/feedBack) that shows a m - **Playback position** — white marker shows current position, active section highlighted - **Always visible** — sits between the HUD and the highway, doesn't obstruct notes - **Automatic** — appears when you play a song, disappears when you leave the player +- **Difficulty glasses (optional)** — each section fills a small glass with its current difficulty, when [Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder) is installed. See below. ## Installation @@ -20,6 +21,41 @@ docker compose restart The section map automatically appears at the top of the player when you play a song. +## Difficulty glasses (optional) + +Section Map is standalone: section colors, labels, click-to-seek and wheel nudging +all work with no other plugin installed. + +The per-section **difficulty glass** is the one optional feature, and it needs +[Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder) +installed alongside it. Difficulty Ladder works out how hard each section is and +publishes that as a fill level and a glass size; Section Map renders whatever it +hands over. Without it, the section map looks the same minus the glasses — no +errors, no placeholder. + +| Requirement | Value | +|---|---| +| Peer plugin | [Difficulty Ladder](https://github.com/get-flashbacks/feedback-plugin-difficulty-ladder), optional | +| Payload floor | **v0.12.0** — the first release documenting the payload shape below | +| Event consumed | `difficulty:sections-updated` (schema `difficulty_ladder.sections.v2`) | +| Feature-detected via | `window._ddCapabilities.sectionDifficulty` | + +Notes on compatibility: + +- "Difficulty Ladder is installed" is not on its own a compatibility statement. + Section Map subscribes only once the peer advertises the capability marker + above; a build that doesn't is treated as not providing glasses. +- Older builds did emit the event (it shipped in v0.2.0), and Section Map cannot + read a peer's version, so they still render glasses — computed with the + formula that plugin used before v0.9.11, which can disagree with Difficulty + Ladder's own HUD. That, not a rendering failure, is what the v0.12.0 floor is + about. If your glasses and Difficulty Ladder's HUD disagree, update it. +- Any payload entry Section Map can't read is dropped rather than painted, so a + malformed or unexpected payload shows no glass instead of a wrong one. +- Difficulty Ladder also emits a newer, render-neutral + `difficulty:sections-updated-v3` payload. Section Map does not consume it yet — + it renders whatever v2 carries, which that plugin keeps emitting unchanged. + ## License MIT diff --git a/plugin.json b/plugin.json index 04e89e9..75ee277 100644 --- a/plugin.json +++ b/plugin.json @@ -1,10 +1,20 @@ { "id": "section_map", "name": "Section Map", - "version": "1.2.7", + "version": "1.3.0", "private": false, "script": "screen.js", "category": "practice", - "description": "Visual song-section map and navigation.", - "icon": "assets/thumb.svg" + "description": "Visual song-section map and navigation. Optional: Difficulty Ladder v0.12.0+ adds per-section difficulty glasses.", + "icon": "assets/thumb.svg", + "capabilities": { + "difficulty-ladder": { + "roles": ["observer"], + "observes": ["difficulty:sections-updated"], + "compatibility": "degrade-noop", + "safety": "safe", + "version": 1, + "description": "Optional peer plugin, used for the difficulty glasses only; section navigation, layout and seeking are standalone. Renders the peer's difficulty_ladder.sections.v2 sectionDifficulties payload (payload floor: Difficulty Ladder v0.12.0) behind window._ddCapabilities.sectionDifficulty. No peer, a peer that doesn't advertise the marker, or a payload entry we can't read all mean no glasses rather than an error." + } + } } diff --git a/screen.js b/screen.js index cfc1062..d6e9d10 100644 --- a/screen.js +++ b/screen.js @@ -1,6 +1,7 @@ // Section Map plugin // Shows a minimap bar of the full song structure with clickable sections. -// Includes optional glass-filling difficulty visualization when dynamic-difficulty is installed. +// Includes optional glass-filling difficulty visualization when the +// Difficulty Ladder plugin (difficulty_ladder) is installed. let _smBar = null; let _smSections = []; @@ -49,40 +50,144 @@ function _smGetColor(name) { return SM_COLORS.default; } -// Check if the difficulty_ladder plugin (formerly "dynamic-difficulty") is -// installed and available. `window._ddCapabilities` is the compatibility -// marker it sets at load time (see difficulty_ladder's screen.js and -// INTEGRATION.md, issue #63) -- the marker name predates that plugin's -// rename from `dynamic_difficulty` to `difficulty_ladder`, kept as-is since -// it's the established capability-marker contract between the two plugins. -// (A second check here used to also probe `window.feedBackViz_dynamic_difficulty`, -// a viz-factory-naming-convention global difficulty_ladder never actually -// set even before the rename -- dead since this function was written.) +function _smSubscribeFeedBackEvent(eventName, handler) { + if (typeof window.feedBack === 'undefined' || typeof window.feedBack.on !== 'function') return null; + const unsubscribe = window.feedBack.on(eventName, handler); + if (typeof unsubscribe === 'function') return unsubscribe; + if (typeof window.feedBack.off === 'function') return () => window.feedBack.off(eventName, handler); + return null; +} + +// The Difficulty Ladder peer contract. Section Map is standalone: everything +// except the per-section "glass fill" indicator works with no peer plugin at +// all. Only the glasses depend on `difficulty_ladder` (formerly +// "dynamic-difficulty"), which owns the difficulty:sections-updated event this +// plugin renders. The full contract lives in that plugin's INTEGRATION.md; the +// parts this file depends on are: +// +// capability window._ddCapabilities.sectionDifficulty === true, set at that +// plugin's top-level script execution. (`_ddCapabilities` predates +// its rename from `dynamic_difficulty` to `difficulty_ladder` and +// is kept as-is: it is the established marker between the two +// plugins.) +// event 'difficulty:sections-updated' (schema difficulty_ladder.sections.v2) +// payload event.detail.sectionDifficulties -- an object map keyed by section +// index, each entry { fillPercentage, glassSize, avgDifficulty, +// maxDifficulty }. A section with no overlapping phrase is simply +// absent from the map, which renders as "no glass". +// floor Difficulty Ladder v0.12.0, the release whose CHANGELOG documents +// the v2 payload shape above. The event itself shipped in v0.2.0, +// but "Difficulty Ladder is installed" is not a compatibility +// statement on its own. Note what the floor is and isn't: the +// marker below has existed since well before v0.2.0 and carries no +// version, so a below-floor peer still passes it and still paints +// glasses. What changed below the floor is the arithmetic behind +// fillPercentage (v0.9.11 aligned it with the peer's own HUD), so +// an old peer renders a glass that can disagree with Difficulty +// Ladder's HUD — not one this plugin can detect. Hence a +// documented floor rather than an enforced version check. +// +// v3 (`difficulty:sections-updated-v3`, a render-neutral payload) is emitted +// alongside v2 on a separate event name and is deliberately NOT consumed yet: +// _smRenderGlassFilling/_smUpdateDifficultyFills render glass metaphors, which is +// exactly what v3 stops prescribing. That is also why the peer's +// `sectionsSchema` advertisement is not a gate below — it exists for a consumer +// choosing between v2 and v3, and the peer keeps emitting v2 unchanged +// alongside v3 either way. +const _SM_DD_SECTIONS_EVENT = 'difficulty:sections-updated'; + +// Check whether the Difficulty Ladder peer is installed AND speaking the +// contract above. A bare `_ddCapabilities` global is not enough -- require the +// named marker, so a foreign plugin that happens to use that global (or a stub +// left behind by an earlier session) leaves the glasses off rather than +// subscribing to an event whose payload shape we cannot render. function _smIsDynamicDifficultyAvailable() { if (typeof window.feedBack === 'undefined') return false; - if (typeof window._ddCapabilities !== 'undefined') return true; - return false; + const caps = window._ddCapabilities; + return !!caps && typeof caps === 'object' && caps.sectionDifficulty === true; } -// Get difficulty data for a section from dynamic-difficulty plugin +// Validate one `difficulty_ladder.sections.v2` payload's section map. +// +// null the container is not an object -- an unusable payload. Callers must +// keep whatever they already have rather than repaint from noise. +// {} a well-formed container with nothing renderable in it, which clears +// the glasses (the "no glass, not a stale one" convention). +// +// Entries are dropped unless they carry a finite numeric `fillPercentage`. The +// peer computes it arithmetically, so it is NaN whenever mastery or the phrase +// difficulty isn't a number — and a NaN sails through the `typeof === 'number'` +// check the renderer used to do, painting `height:NaN%` and a "Difficulty: NaN%" +// title. The same check drops anything from a payload that isn't the v2 shape at +// all, so an entry we can't read renders no glass instead of a misleading +// near-empty one. +function _smNormalizeSectionDifficulties(raw) { + if (!raw || typeof raw !== 'object') return null; + const normalized = {}; + for (const key of Object.keys(raw)) { + const entry = raw[key]; + if (!entry || typeof entry !== 'object') continue; + if (!Number.isFinite(entry.fillPercentage)) continue; + normalized[key] = entry; + } + return normalized; +} + +// Get difficulty data for a section from the Difficulty Ladder peer function _smGetSectionDifficulty(sectionIndex) { if (!_smDynamicDifficultyAvailable) return null; - // This would be populated by a capability or event from dynamic-difficulty - // For now, return from cache if available + // Populated by difficulty_ladder's difficulty:sections-updated event. return _smSectionDifficulty[sectionIndex] || null; } -// Initialize difficulty data listener if dynamic-difficulty is available +// Initialize difficulty data listener if the Difficulty Ladder peer is available function _smInitializeDifficultyListener() { _smDynamicDifficultyAvailable = _smIsDynamicDifficultyAvailable(); } -function _smSubscribeFeedBackEvent(eventName, handler) { - if (typeof window.feedBack === 'undefined' || typeof window.feedBack.on !== 'function') return null; - const unsubscribe = window.feedBack.on(eventName, handler); - if (typeof unsubscribe === 'function') return unsubscribe; - if (typeof window.feedBack.off === 'function') return () => window.feedBack.off(eventName, handler); - return null; +// difficulty:sections-updated handler. +function _smOnSectionsUpdated(event) { + const normalized = _smNormalizeSectionDifficulties(event && event.detail && event.detail.sectionDifficulties); + if (normalized === null) return; + _smSectionDifficulty = normalized; + // Difficulty refreshes change only the per-section glass fill, not + // colors/labels/positions — update those in place rather than paying for a + // full _smRender() (which tears down and rebuilds every block's DOM) on + // every event, which can fire repeatedly over the course of a song. Falls + // back to a full render if the cached blocks don't match the current + // section list (bar not built for these sections yet). + if (_smBar && _smSections.length > 0 && _smBlockEls.length === _smSections.length) { + _smUpdateDifficultyFills(); + } else { + _smRender(); + } +} + +// Subscribe to the peer's section-difficulty event if it is available yet. +// +// This re-runs rather than deciding once per mount on purpose. The peer sets +// its marker at top-level script execution and emits no readiness event, and +// the Host does not order plugin loads — core's session.js is explicit that +// "plugins load ASYNCHRONOUSLY, so [a wrapper chain] links up in whatever order +// the race settles". _smUpdate() calls this on every 200ms tick while the player +// is visible: one property read per tick, short-circuiting on a single boolean +// once registered. +// +// One-directional on purpose: a peer that vanishes mid-song leaves its last +// glasses in place rather than tearing down mid-render, and the contract is +// that an absent peer means no glasses, never an error. +function _smEnsureDifficultySubscription() { + if (_smDifficultySubscribed) return; + _smInitializeDifficultyListener(); + if (!_smDynamicDifficultyAvailable) return; + const bus = window.feedBack; + if (!bus || typeof bus.on !== 'function') return; // host bus not up yet -- retry next tick + // From here a handler IS registered, so _smDifficultySubscribed means + // "registered", not "removable": the bus isn't required to return an + // unsubscribe handle, and re-registering every 200ms because it didn't + // would leak a handler per tick. + _smDifficultySubscribed = true; + _smDifficultyUnsubscribe = _smSubscribeFeedBackEvent(_SM_DD_SECTIONS_EVENT, _smOnSectionsUpdated); } function _smStartRealtimeHooks() { @@ -98,28 +203,7 @@ function _smStartRealtimeHooks() { _smSongReadySubscribed = typeof _smSongReadyUnsubscribe === 'function'; } - _smInitializeDifficultyListener(); - if (_smDynamicDifficultyAvailable && !_smDifficultySubscribed) { - _smDifficultyUnsubscribe = _smSubscribeFeedBackEvent('difficulty:sections-updated', (event) => { - const { sectionDifficulties } = event.detail || {}; - if (sectionDifficulties) { - _smSectionDifficulty = sectionDifficulties; - // Difficulty refreshes change only the per-section glass fill, - // not colors/labels/positions — update those in place rather - // than paying for a full _smRender() (which tears down and - // rebuilds every block's DOM) on every event, which can fire - // repeatedly over the course of a song. Falls back to a full - // render if the cached blocks don't match the current - // section list (bar not built for these sections yet). - if (_smBar && _smSections.length > 0 && _smBlockEls.length === _smSections.length) { - _smUpdateDifficultyFills(); - } else { - _smRender(); - } - } - }); - _smDifficultySubscribed = typeof _smDifficultyUnsubscribe === 'function'; - } + _smEnsureDifficultySubscription(); } function _smStopRealtimeHooks() { @@ -244,6 +328,11 @@ function _smOnWheel(e) { } function _smUpdate() { + // Retry the Difficulty Ladder handshake before anything else: the peer may + // have loaded (or reloaded) after this plugin did, and glasses are the one + // thing that can't wait for the next song. No-op once subscribed. + _smEnsureDifficultySubscription(); + if (!_smBar) return; const sections = highway.getSections(); const info = highway.getSongInfo(); @@ -373,21 +462,34 @@ function _smRender() { _smActiveIdx = -1; } +// Size buckets for the glass, keyed by the difficulty_ladder.sections.v2 +// `glassSize` value. Module-level because two call sites need it: the renderer +// below and _smUpdateDifficultyFills()'s size-change comparison. +const SM_GLASS_SIZES = { + small: 'width:12px;height:12px;', + medium: 'width:16px;height:16px;', + large: 'width:20px;height:20px;', +}; + +// The one place a peer's `glassSize` enters our markup: it lands unescaped in +// the `data-size` attribute, so resolve it against the allowlist first rather +// than trusting the string. Anything unrecognized (including markup) becomes +// `medium`, matching the style fallback. +function _smGlassSize(difficulty) { + const size = difficulty && difficulty.glassSize; + return Object.prototype.hasOwnProperty.call(SM_GLASS_SIZES, size) ? size : 'medium'; +} + // Render glass-filling visualization for section difficulty function _smRenderGlassFilling(difficulty) { - if (!difficulty || typeof difficulty.fillPercentage !== 'number') return ''; + // isFinite, not typeof: the peer derives this arithmetically and a NaN + // mastery or phrase difficulty yields NaN, which is typeof 'number' and + // would otherwise reach the style attribute as `height:NaN%`. + if (!difficulty || !Number.isFinite(difficulty.fillPercentage)) return ''; const fillPct = Math.max(0, Math.min(100, difficulty.fillPercentage)); - const glassSize = difficulty.glassSize || 'medium'; // small, medium, large - - // Size classes for the glass - const sizeStyles = { - small: 'width:12px;height:12px;', - medium: 'width:16px;height:16px;', - large: 'width:20px;height:20px;', - }; - - const glassStyle = sizeStyles[glassSize] || sizeStyles.medium; + const glassSize = _smGlassSize(difficulty); + const glassStyle = SM_GLASS_SIZES[glassSize]; // Classed (and size-tagged) so _smUpdateDifficultyFills() can update an // existing glass's fill height/title in place on a difficulty refresh, @@ -402,10 +504,10 @@ function _smRenderGlassFilling(difficulty) { // Refresh only the per-section difficulty "glass fill" indicators in place // (fill height + title) instead of rebuilding the whole section-map bar. // Called from the 'difficulty:sections-updated' event, which can fire -// repeatedly while a song plays as dynamic-difficulty tracks accuracy — none -// of that data changes section colors/labels/positions, so a full _smRender() -// on every event would tear down and rebuild every block's DOM just to -// repaint a handful of small fill bars. +// repeatedly while a song plays as difficulty_ladder re-derives each section's +// difficulty — none of that data changes section colors/labels/positions, so a +// full _smRender() on every event would tear down and rebuild every block's DOM +// just to repaint a handful of small fill bars. function _smUpdateDifficultyFills() { if (!_smBar || !_smBlockEls.length) return; for (let i = 0; i < _smBlockEls.length; i++) { @@ -413,9 +515,9 @@ function _smUpdateDifficultyFills() { if (!block || typeof block.querySelector !== 'function') continue; const difficulty = _smGetSectionDifficulty(i); const glass = block.querySelector('.sm-glass'); - if (_smDynamicDifficultyAvailable && difficulty && typeof difficulty.fillPercentage === 'number') { + if (_smDynamicDifficultyAvailable && difficulty && Number.isFinite(difficulty.fillPercentage)) { const fillPct = Math.max(0, Math.min(100, difficulty.fillPercentage)); - const glassSize = difficulty.glassSize || 'medium'; + const glassSize = _smGlassSize(difficulty); const sizeChanged = glass && glass.getAttribute && glass.getAttribute('data-size') !== glassSize; if (glass && !sizeChanged) { // Same glass already present (the common case while a song @@ -456,10 +558,12 @@ if (typeof module !== 'undefined' && module.exports) { _smOnClick, _smOnWheel, _smEscapeHtml, _smIsDynamicDifficultyAvailable, _smGetSectionDifficulty, _smRenderGlassFilling, _smUpdateDifficultyFills, + _smNormalizeSectionDifficulties, _smOnSectionsUpdated, _smEnsureDifficultySubscription, _smInitializeDifficultyListener, _smStartRealtimeHooks, _smStopRealtimeHooks, _smSetPlayerVisible, _getState: () => ({ bar: _smBar, sections: _smSections, duration: _smDuration, sectionDifficulty: _smSectionDifficulty, ddAvailable: _smDynamicDifficultyAvailable, markerEl: _smMarkerEl, blockEls: _smBlockEls, activeIdx: _smActiveIdx, + songReadySubscribed: _smSongReadySubscribed, difficultySubscribed: _smDifficultySubscribed, }), _setState(next) { if ('sections' in next) _smSections = next.sections; diff --git a/tests/screen.test.js b/tests/screen.test.js index 463f31f..c4ce1a1 100644 --- a/tests/screen.test.js +++ b/tests/screen.test.js @@ -574,3 +574,358 @@ test('_smSetPlayerVisible retries event subscription after feedBack becomes avai else global.window.feedBack = originalFeedBack; } }); + + +// Difficulty Ladder peer contract (issue #13): absent, late-loaded, minimum, and +// current providers, plus malformed and older payloads. The glasses are the only +// feature that depends on the peer, so every one of these has to leave base +// section navigation working. + +// Minimal stand-in for a host event bus: records (event, handler) so a test can +// fire the peer's event exactly as the host would, and count subscriptions. +function fakeEventBus() { + const handlers = new Map(); + return { + calls: [], + on(eventName, handler) { + this.calls.push(eventName); + if (!handlers.has(eventName)) handlers.set(eventName, []); + handlers.get(eventName).push(handler); + return () => { + const list = handlers.get(eventName) || []; + const i = list.indexOf(handler); + if (i >= 0) list.splice(i, 1); + }; + }, + count(eventName) { return (handlers.get(eventName) || []).length; }, + fire(eventName, event) { + for (const handler of [...(handlers.get(eventName) || [])]) handler(event); + }, + }; +} + +// A difficulty_ladder.sections.v2 payload, as difficulty_ladder emits it: an +// object map keyed by section index, one entry per section that has an +// overlapping phrase (sections with none are simply absent). +function v2Payload(entries) { + return { + schema: 'difficulty_ladder.sections.v2', + sectionDifficulties: entries, + mastery: 0.5, + maxDifficulty: 4, + }; +} + +test('peer contract: no provider means no subscription and no glasses', () => { + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + assert.equal(mod._smIsDynamicDifficultyAvailable(), false); + + mod._smEnsureDifficultySubscription(); + mod._smEnsureDifficultySubscription(); + + assert.equal(mod._getState().ddAvailable, false); + assert.deepEqual(bus.calls, [], 'must not subscribe without the capability marker'); + mod._setState({ sectionDifficulty: { 0: { fillPercentage: 50 } } }); + assert.equal(mod._smGetSectionDifficulty(0), null, 'cached data stays unreachable while the peer is absent'); +}); + +test('peer contract: a bare _ddCapabilities global is not a provider', () => { + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + // Some other plugin using the same global name, or a ladder build from + // before the v2 contract existed. Either way, no sectionDifficulty marker. + global.window._ddCapabilities = { somethingElse: true }; + try { + assert.equal(mod._smIsDynamicDifficultyAvailable(), false); + mod._smEnsureDifficultySubscription(); + assert.deepEqual(bus.calls, []); + } finally { + delete global.window._ddCapabilities; + } +}); + +test('peer contract: a late-loading provider is picked up by the next _smUpdate tick', () => { + // The original handshake decided once, at player-screen mount, which relied + // on the Host loading plugins alphabetically (difficulty_ladder < + // section_map). A deferred or reloaded plugin load left the feature + // unsubscribed for the rest of the session; the retry is the poller. + const mod = freshPlugin(); + const originalHighway = global.highway; + const bus = fakeEventBus(); + global.window.feedBack = bus; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; + try { + mod._setState({ bar: new FakeBar() }); + mod._smUpdate(); // player already visible, peer not loaded yet + assert.equal(bus.count('difficulty:sections-updated'), 0); + + global.window._ddCapabilities = { sectionDifficulty: true }; + mod._smUpdate(); + assert.equal(bus.count('difficulty:sections-updated'), 1, 'subscribed on the next tick'); + + mod._smUpdate(); + mod._smUpdate(); + assert.equal(bus.count('difficulty:sections-updated'), 1, 'subscribed exactly once'); + } finally { + delete global.window._ddCapabilities; + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + } +}); + +test('peer contract: a minimum (v0.12.0) provider payload renders glasses', () => { + // v0.12.0 is the floor: the first release documenting the v2 payload shape. + // It advertises sectionDifficulty and no sectionsSchema (v3 came later). + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + global.window._ddCapabilities = { sectionDifficulty: true }; + try { + mod._smEnsureDifficultySubscription(); + assert.equal(mod._getState().ddAvailable, true); + + bus.fire('difficulty:sections-updated', { detail: v2Payload({ + 0: { fillPercentage: 40, glassSize: 'small', avgDifficulty: 2, maxDifficulty: 2 }, + 1: { fillPercentage: 100, glassSize: 'large', avgDifficulty: 4, maxDifficulty: 4 }, + }) }); + + const state = mod._getState(); + assert.equal(state.sectionDifficulty[0].fillPercentage, 40); + assert.equal(state.sectionDifficulty[1].glassSize, 'large'); + // A section the peer reported nothing for is "no glass", not a zero fill. + assert.equal(mod._smGetSectionDifficulty(2), null); + } finally { + delete global.window._ddCapabilities; + } +}); + +test('peer contract: a current provider payload renders in place without a full re-render', () => { + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + // Current builds also advertise sectionsSchema, and emit v2 alongside v3. + global.window._ddCapabilities = { + sectionDifficulty: true, + sectionsSchema: 'difficulty_ladder.sections.v3', + }; + try { + mod._smEnsureDifficultySubscription(); + const bar = new FakeBar(); + const block = new FakeDifficultyBlock(new FakeGlass('medium', 10)); + mod._setState({ + bar, + sections: [{ name: 'Intro', time: 0 }], + duration: 10, + blockEls: [block], + sectionDifficulty: {}, + }); + + bus.fire('difficulty:sections-updated', { detail: v2Payload({ + 0: { fillPercentage: 60, glassSize: 'medium' }, + }) }); + + assert.equal(block.insertedHTML.length, 0, 'existing glass updated in place'); + assert.equal(block._glass._fill.style.height, '60%'); + assert.equal(block._glass.title, 'Difficulty: 60%'); + } finally { + delete global.window._ddCapabilities; + } +}); + +test('peer contract: an event with no detail leaves the last good state alone', () => { + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + global.window._ddCapabilities = { sectionDifficulty: true }; + try { + mod._smEnsureDifficultySubscription(); + bus.fire('difficulty:sections-updated', { detail: v2Payload({ 0: { fillPercentage: 25, glassSize: 'small' } }) }); + + bus.fire('difficulty:sections-updated', undefined); + bus.fire('difficulty:sections-updated', { detail: {} }); + bus.fire('difficulty:sections-updated', { detail: { sectionDifficulties: 'nope' } }); + + assert.equal(mod._getState().sectionDifficulty[0].fillPercentage, 25, 'malformed payloads must not clear good data'); + } finally { + delete global.window._ddCapabilities; + } +}); + +test('peer contract: an unreadable payload entry paints no glass', () => { + // The peer computes fillPercentage arithmetically, so it is NaN whenever + // mastery or the phrase difficulty isn't a number -- and NaN is typeof + // 'number', which used to sail through the renderer's own check and paint + // `height:NaN%`. A payload that isn't the v2 shape at all is dropped the + // same way: an entry we can't read renders no glass rather than a + // misleading near-empty one. + const mod = freshPlugin(); + const bus = fakeEventBus(); + global.window.feedBack = bus; + global.window._ddCapabilities = { sectionDifficulty: true }; + try { + mod._smEnsureDifficultySubscription(); + mod._setState({ sectionDifficulty: { 0: { fillPercentage: 25, glassSize: 'small' } } }); + + bus.fire('difficulty:sections-updated', { detail: { + sectionDifficulties: { + 0: { fillPercentage: NaN }, // peer arithmetic went NaN + 1: { fillPercentage: '50' }, // string, not a number + 2: { fillPercentage: null }, + 3: null, // not even an object + 4: { glassSize: 'small' }, // no fill field at all + }, + } }); + + const state = mod._getState(); + assert.deepEqual(Object.keys(state.sectionDifficulty), []); + assert.equal(mod._smGetSectionDifficulty(0), null, 'unreadable entries render no glass'); + assert.equal(mod._smRenderGlassFilling({ fillPercentage: NaN }), '', 'NaN fill renders nothing at all'); + } finally { + delete global.window._ddCapabilities; + } +}); + +test('_smRenderGlassFilling only ever writes an allowlisted glassSize into markup', () => { + // glassSize is the one payload field that reaches an HTML attribute + // unescaped (data-size), so an unrecognized value must resolve to `medium` + // rather than being interpolated verbatim. + const mod = freshPlugin(); + for (const size of ['small', 'medium', 'large']) { + assert.ok( + mod._smRenderGlassFilling({ fillPercentage: 50, glassSize: size }).includes(`data-size="${size}"`), + `${size} renders its own bucket`, + ); + } + for (const bogus of ['huge', '">', undefined, null]) { + const html = mod._smRenderGlassFilling({ fillPercentage: 50, glassSize: bogus }); + assert.ok(html.includes('data-size="medium"'), `${bogus} falls back to medium`); + assert.ok(!html.includes(' { + // _smDifficultySubscribed is written from two places (the mount path and the + // per-tick retry) and reset on hide; a double-subscribe here would double + // every difficulty repaint for the rest of the session. + const mod = freshPlugin(); + const originalSetInterval = global.setInterval; + const originalClearInterval = global.clearInterval; + const originalHighway = global.highway; + const bus = fakeEventBus(); + + global.window.feedBack = bus; + global.window._ddCapabilities = { sectionDifficulty: true }; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; + global.setInterval = () => 1; + global.clearInterval = () => {}; + + try { + mod._smSetPlayerVisible(true); + assert.equal(bus.count('difficulty:sections-updated'), 1); + + mod._smSetPlayerVisible(false); + assert.equal(bus.count('difficulty:sections-updated'), 0, 'hide must detach'); + + mod._smSetPlayerVisible(true); + assert.equal(bus.count('difficulty:sections-updated'), 1, 're-show re-subscribes exactly once'); + + // And a tick must not add a second handler on top. + mod._smUpdate(); + mod._smUpdate(); + assert.equal(bus.count('difficulty:sections-updated'), 1); + } finally { + mod._smSetPlayerVisible(false); + global.setInterval = originalSetInterval; + global.clearInterval = originalClearInterval; + delete global.window._ddCapabilities; + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + } +}); + +test('peer contract: the per-tick retry never re-registers when the bus hands back no unsubscribe', () => { + // The bus is not required to return an unsubscribe handle (a plain + // EventTarget's on/off returns undefined). Registering once and keeping the + // handler is the only correct outcome -- re-registering every 200ms would + // leak a handler per tick. + const mod = freshPlugin(); + const originalHighway = global.highway; + let registrations = 0; + global.window.feedBack = { + on() { registrations++; return undefined; }, + }; + global.window._ddCapabilities = { sectionDifficulty: true }; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; + try { + mod._setState({ bar: new FakeBar() }); + for (let i = 0; i < 5; i++) mod._smUpdate(); + assert.equal(registrations, 1); + } finally { + delete global.window._ddCapabilities; + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + } +}); + +test('peer contract: the retry waits for the host bus when the peer is already up', () => { + // Peer present, host event bus not: nothing to subscribe to yet, so keep + // retrying rather than marking the subscription done. + const mod = freshPlugin(); + const originalHighway = global.highway; + global.window._ddCapabilities = { sectionDifficulty: true }; + global.highway = { + getSections: () => [], + getSongInfo: () => ({ duration: 0 }), + getTime: () => 0, + }; + try { + mod._setState({ bar: new FakeBar() }); + mod._smUpdate(); + assert.equal(mod._getState().difficultySubscribed, false); + + const bus = fakeEventBus(); + global.window.feedBack = bus; + mod._smUpdate(); + assert.equal(bus.count('difficulty:sections-updated'), 1); + } finally { + delete global.window._ddCapabilities; + if (typeof originalHighway === 'undefined') delete global.highway; + else global.highway = originalHighway; + } +}); + +test('_smNormalizeSectionDifficulties separates an unusable payload from an empty one', () => { + const mod = freshPlugin(); + // null: not an object at all -> "ignore the event", keep current state. + assert.equal(mod._smNormalizeSectionDifficulties(undefined), null); + assert.equal(mod._smNormalizeSectionDifficulties(null), null); + assert.equal(mod._smNormalizeSectionDifficulties(7), null); + // {}: a well-formed map with nothing renderable -> "clear the glasses". + assert.deepEqual(mod._smNormalizeSectionDifficulties({}), {}); + assert.deepEqual(mod._smNormalizeSectionDifficulties({ 0: 'nope' }), {}); + // A 0% fill is real data (a section with no chart content), not a missing one. + const zeroFill = mod._smNormalizeSectionDifficulties({ 0: { fillPercentage: 0, glassSize: 'small' } }); + assert.equal(zeroFill[0].fillPercentage, 0); + // Surrounding keys are not the contract: the producer keys by section index, + // and only the entries we can render survive. + assert.deepEqual( + Object.keys(mod._smNormalizeSectionDifficulties({ 0: { fillPercentage: 1 }, schema: 'x' })), + ['0'], + ); +}); + From 81a91bfad71c832df013facfe037721f6bed560e Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:05:20 +0000 Subject: [PATCH 2/5] Correct the reason the peer handshake must be retried The retry itself was already right, but the rationale written for it was not: it claimed core does not order plugin loads, citing session.js's note that "plugins load ASYNCHRONOUSLY" (which is about the plugin/navigation wrapper chain, not competing plugin scripts). static/js/plugin-loader.js is more specific, and the real answer is stronger than "ordering isn't guaranteed": - it sorts plugins by `name || id`, ties broken by id, and injects screen.js one at a time awaiting each onload, so with both plugins 'ready' in a single response difficulty_ladder really does sort ahead of section_map; - but it also skips any plugin whose status isn't 'ready', and the backend clears its registry at the start of load_plugins() and repopulates it incrementally, so a response can be partial; - and it refetches on `plugin-registered` startup events. So a peer mid-install lands on a later refetch, after our script body ran, and our script does not run again either (loadedScripts guard). The tick remains the only retry - only the explanation of why it is needed changes. --- CLAUDE.md | 36 ++++++++++++++++++++++-------------- screen.js | 23 ++++++++++++++++------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 81150c1..b2dc53f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,20 +42,28 @@ reading state straight off the Host's `window.highway` object. `_smEnsureDifficultySubscription()` runs from `_smStartRealtimeHooks()` *and* from every `_smUpdate()` tick, so a Difficulty Ladder that loads or reloads after this plugin is still picked up mid-session. Don't reintroduce a - one-shot check: the old one leaned on alphabetical plugin loading - (`difficulty_ladder` < `section_map`), which core does not actually promise — - `feedBack/static/js/session.js` is explicit that "plugins load ASYNCHRONOUSLY, - so the chain links up in whatever order the race settles". There is no - readiness event to subscribe to either — the peer sets its marker at top-level - script execution and declares no capability domain — so the 200 ms tick is the - retry. `_smIsDynamicDifficultyAvailable()` requires - `window._ddCapabilities.sectionDifficulty === true`, not merely that some - `_ddCapabilities` global exists, so a foreign user of that global can't unlock - a subscription we can't render. That function must stay reachable-before-bus: - it returns early when `feedBack.on` isn't a function yet, and once it has - registered a handler `_smDifficultySubscribed` stays true even if the bus - returned no unsubscribe handle — otherwise the tick would re-register one - handler per 200ms. + one-shot check, and don't reintroduce the *wrong reason* for removing it + either. The accurate version: when both plugins are 'ready' in one + `/api/plugins` response, core's `static/js/plugin-loader.js` injects `screen.js` + files one at a time in sorted order (`name || id`, ties broken by id), so + `difficulty_ladder` does sort ahead of `section_map` and a one-shot check + happened to work. That ordering is a property of that one response, not a + promise — the loader skips any plugin whose status isn't `'ready'`, refetches + on `plugin-registered` startup events, and the backend "clears its plugin + registry at the start of `load_plugins()` and repopulates it incrementally + while HTTP stays up, so every backend restart serves a window of partial (even + empty) responses". A mid-install peer therefore lands on a later refetch, after + our script body has run, and our script does not re-run either (the loader's + `loadedScripts` guard), so the 200 ms tick is the only retry. The peer emits no + readiness event to subscribe to — it just sets its marker at top-level script + execution and declares no capability domain. `_smIsDynamicDifficultyAvailable()` + requires `window._ddCapabilities.sectionDifficulty === true`, not merely that + some `_ddCapabilities` global exists, so a foreign user of that global can't + unlock a subscription we can't render. That function must stay + reachable-before-bus: it returns early when `feedBack.on` isn't a function yet, + and once it has registered a handler `_smDifficultySubscribed` stays true even + if the bus returned no unsubscribe handle — otherwise the tick would + re-register one handler per 200ms. - **The peer compatibility floor is Difficulty Ladder v0.12.0, not v0.2.0.** v0.2.0 is when the *event* first shipped; v0.12.0 is where that plugin's CHANGELOG documents the `difficulty_ladder.sections.v2` payload shape this diff --git a/screen.js b/screen.js index d6e9d10..b5e4f7d 100644 --- a/screen.js +++ b/screen.js @@ -165,13 +165,22 @@ function _smOnSectionsUpdated(event) { // Subscribe to the peer's section-difficulty event if it is available yet. // -// This re-runs rather than deciding once per mount on purpose. The peer sets -// its marker at top-level script execution and emits no readiness event, and -// the Host does not order plugin loads — core's session.js is explicit that -// "plugins load ASYNCHRONOUSLY, so [a wrapper chain] links up in whatever order -// the race settles". _smUpdate() calls this on every 200ms tick while the player -// is visible: one property read per tick, short-circuiting on a single boolean -// once registered. +// This re-runs rather than deciding once per mount on purpose. When both +// plugins come back 'ready' in one /api/plugins response, core injects screen.js +// files one at a time in sorted order (`name || id`, ties broken by id), so +// difficulty_ladder sorts ahead of section_map and the old one-shot check +// happened to work. That ordering is a property of that one response, not a +// guarantee: static/js/plugin-loader.js skips any plugin whose status isn't +// 'ready', and the backend "clears its plugin registry at the start of +// load_plugins() and repopulates it incrementally while HTTP stays up, so every +// backend restart serves a window of partial (even empty) responses". A peer +// that is mid-install then arrives on a later refetch (the loader refetches on +// `plugin-registered` startup events) — after our script body has already run, +// and our script does not run again either, so nothing else would ever retry. +// The peer emits no readiness event to subscribe to either; it just sets its +// marker at top-level script execution. So _smUpdate() calls this on every +// 200ms tick while the player is visible: one property read per tick, +// short-circuiting on a single boolean once registered. // // One-directional on purpose: a peer that vanishes mid-song leaves its last // glasses in place rather than tearing down mid-render, and the contract is From 58cb226b4fac7bdb48a484b7bfb88afa2f483eb2 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:13:17 +0000 Subject: [PATCH 3/5] Drop the useless spread in the fake event bus's fire() CodeFactor flagged unicorn/no-useless-spread on fire(): it copied the handler list before iterating. Make the fake bus copy-on-write in on() instead, so fire() can iterate the live list directly -- same dispatch semantics, no throwaway array. --- tests/screen.test.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/screen.test.js b/tests/screen.test.js index c4ce1a1..9458752 100644 --- a/tests/screen.test.js +++ b/tests/screen.test.js @@ -589,17 +589,16 @@ function fakeEventBus() { calls: [], on(eventName, handler) { this.calls.push(eventName); - if (!handlers.has(eventName)) handlers.set(eventName, []); - handlers.get(eventName).push(handler); + // Replace the list rather than mutating it: fire() iterates the live + // list, and a handler is free to (un)subscribe while it runs. + handlers.set(eventName, (handlers.get(eventName) || []).concat(handler)); return () => { - const list = handlers.get(eventName) || []; - const i = list.indexOf(handler); - if (i >= 0) list.splice(i, 1); + handlers.set(eventName, (handlers.get(eventName) || []).filter((h) => h !== handler)); }; }, count(eventName) { return (handlers.get(eventName) || []).length; }, fire(eventName, event) { - for (const handler of [...(handlers.get(eventName) || [])]) handler(event); + for (const handler of handlers.get(eventName) || []) handler(event); }, }; } From fbe601c962615a3b7c8855c3a17056d2e80c5c8f Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:41:06 +0000 Subject: [PATCH 4/5] Fix: keep subscription flag when bus has no disposer; all tests pass --- screen.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/screen.js b/screen.js index b5e4f7d..ca48a97 100644 --- a/screen.js +++ b/screen.js @@ -229,9 +229,11 @@ function _smStopRealtimeHooks() { if (typeof _smDifficultyUnsubscribe === 'function') { _smDifficultyUnsubscribe(); + _smDifficultyUnsubscribe = null; + _smDifficultySubscribed = false; } - _smDifficultyUnsubscribe = null; - _smDifficultySubscribed = false; + // If there's no disposer, keep _smDifficultySubscribed true to prevent + // accumulating handlers on hide/show cycles (bus returned no unsubscribe handle). } function _smSetPlayerVisible(isVisible) { From 3c961a8d38e3a4d6568df8adc17690b86b57028c Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:44:26 +0000 Subject: [PATCH 5/5] Correct the fakeEventBus comment's rationale fire() reads the handler list once when the loop starts, and both on() and the disposer replace the array instead of mutating it, so the iteration is over the snapshot captured at entry. The previous wording claimed it iterated the live list. --- tests/screen.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/screen.test.js b/tests/screen.test.js index 9458752..9c74dd5 100644 --- a/tests/screen.test.js +++ b/tests/screen.test.js @@ -589,8 +589,9 @@ function fakeEventBus() { calls: [], on(eventName, handler) { this.calls.push(eventName); - // Replace the list rather than mutating it: fire() iterates the live - // list, and a handler is free to (un)subscribe while it runs. + // Replace the list rather than mutating it: fire() captures the list + // when it starts, so a handler that subscribes or unsubscribes + // mid-dispatch can't disturb the dispatch already in progress. handlers.set(eventName, (handlers.get(eventName) || []).concat(handler)); return () => { handlers.set(eventName, (handlers.get(eventName) || []).filter((h) => h !== handler));