From a44ebd7573aef690b282025ba76c73564d2512c8 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 20 Aug 2026 15:37:34 -0700 Subject: [PATCH] fix(studio): compare the reveal request across the id-space boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's store guard was silently never true. A reveal request carries the BARE dom id — `revealElementId = runtimeAudioId(keyframeClip)`, because the panel and the runtime speak that — while the store's ids are `sourceFile#domId`. So `elementKey === id` compared "music-bed" against "index.html#music-bed" and the request was still cleared by its own selection. This is the id-space boundary the branch's own handoff names as the trap most likely to be re-broken, and it fails exactly as documented: no error, the feature just never matches. Caught only by driving the real UI — the store said `selected: "index.html#music-bed"` and `revealSurvived: null` in the same read. `revealTargetsSelection` now splits the key with the existing `splitTimelineElementKey` and is a named function in `playerStoreSelection.ts` so the crossing is stated once, where it happens. **Verified in the browser, both halves, which had never been watched working:** - clip NOT selected: clicking a lane label selects the clip, the rack opens, and the carve module expands (correct — that lane's node is a carve band). The request is consumed and retired to null by the new unmount clear. - clip ALREADY selected: collapsing the module and clicking the same lane again reopens it, which the old value-equality consumption could not do. studio player: 103 files, 1355 tests. fallow clean. --- .../studio/src/player/store/playerStore.ts | 10 +++++++-- .../src/player/store/playerStoreSelection.ts | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index adf60e723..6e090bba2 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { attachPlayerStoreDevHandle } from "./playerStoreDevHandle"; -import { nextSelectionSet } from "./playerStoreSelection"; +import { nextSelectionSet, revealTargetsSelection } from "./playerStoreSelection"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { BeatEditState } from "../../utils/beatEditing"; @@ -541,7 +541,13 @@ export const usePlayerStore = create((set, get) => ({ // selection lands afterwards and used to clear the very request that // caused it — the panel then read null and the section never opened. // Any OTHER selection still drops it: a request aimed elsewhere is stale. - const revealSurvives = s.revealedAudioFxTarget?.elementKey === id; + // + // Compared across the ID-SPACE BOUNDARY, which is why this needs saying: + // a request carries the BARE dom id (`runtimeAudioId`, because the panel + // and the runtime speak that), while this store's ids are + // `sourceFile#domId`. A direct `===` was silently never true — the exact + // shape of failure the id-space split produces. + const revealSurvives = revealTargetsSelection(s.revealedAudioFxTarget, id); return id !== s.selectedElementId ? { selectedElementId: id, diff --git a/packages/studio/src/player/store/playerStoreSelection.ts b/packages/studio/src/player/store/playerStoreSelection.ts index 33817091b..ab514399b 100644 --- a/packages/studio/src/player/store/playerStoreSelection.ts +++ b/packages/studio/src/player/store/playerStoreSelection.ts @@ -1,9 +1,12 @@ /** - * Selection-set arithmetic for the player store. + * Selection-set arithmetic for the player store, and the reveal request's + * cross-id-space match. * * Its own module so `playerStore.ts` stays under the studio's 600-line cap. */ +import { splitTimelineElementKey } from "../lib/timelineElementHelpers"; + /** * The id set a selection change leaves behind. * @@ -19,3 +22,20 @@ export function nextSelectionSet( if (preserveSet) return id && current.has(id) ? new Set(current) : new Set(); return id ? new Set([id]) : new Set(); } + +/** + * Is a pending reveal request aimed at the element being selected? + * + * Compared across the ID-SPACE BOUNDARY, which is why it is a named function: + * a request carries the BARE dom id (`runtimeAudioId` — the panel and the + * runtime speak that), while the store's ids are `sourceFile#domId`. A direct + * `===` between the two is silently never true, which is the exact shape of + * failure the split produces and how the reveal came to be dead. + */ +export function revealTargetsSelection( + request: { elementKey: string } | null, + id: string | null, +): boolean { + if (!request || id === null) return false; + return request.elementKey === splitTimelineElementKey(id).domId; +}