From ba0d6406d23d11e12305ba79834a69ae672b8432 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 28 Jul 2026 21:21:27 +0200 Subject: [PATCH] fix(studio): scope lane ids per timeline and stop inventing a track row Two latent defects in the announcement path this branch adds. - lanesId was keyed by render row alone, so a second TimelineLanes on the page (a mini-timeline beside the main one) would mint the same timeline-lanes-track-0 and every caret's aria-controls would resolve to whichever instance mounted first. The prefix now comes from useId, with the colons stripped so the id stays a legal CSS selector. - trackDisplayNumber returned trackOrder.length + 1 for a key it could not find, which is indistinguishable from a real row: the label announced a row the user can see is wrong and nothing upstream could tell it had guessed. It returns null now, and trackDisplaySuffix drops the number from the label rather than inventing one. --- .../src/hooks/timelineTrackVisibility.ts | 12 +++++-- .../player/components/TimelineLanes.test.tsx | 24 +++++++++++++ .../src/player/components/TimelineLanes.tsx | 16 +++++++-- .../player/components/TimelineTrackHeader.tsx | 11 +++--- .../components/timelineTrackDisplay.test.ts | 35 +++++++++++++++++++ .../player/components/timelineTrackDisplay.ts | 22 +++++++++--- 6 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 packages/studio/src/player/components/timelineTrackDisplay.test.ts diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts index 634ea6ee6..7860ad38b 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.ts @@ -1,7 +1,11 @@ import { useCallback } from "react"; import { usePlayerStore, type TimelineElement } from "../player"; import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements"; -import { timelineTrackOrder, trackDisplayNumber } from "../player/components/timelineTrackDisplay"; +import { + timelineTrackOrder, + trackDisplayNumber, + trackDisplaySuffix, +} from "../player/components/timelineTrackDisplay"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher"; import { @@ -211,13 +215,15 @@ export async function toggleTimelineTrackHidden({ }: ToggleTimelineTrackHiddenInput): Promise { // `track` is the fractional sort key the callback needs; the history entry is // read by a human, so it gets the display row instead. - const displayNumber = trackDisplayNumber(timelineTrackOrder(timelineElements), track); + const suffix = trackDisplaySuffix( + trackDisplayNumber(timelineTrackOrder(timelineElements), track), + ); return setElementsHidden({ projectId, activeCompPath, elements: timelineElements.filter((element) => element.track === track), hidden, - label: hidden ? `Hide track ${displayNumber}` : `Show track ${displayNumber}`, + label: hidden ? `Hide track${suffix}` : `Show track${suffix}`, previewIframe, writeProjectFile, recordEdit, diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index eecc98f89..248b1675a 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -215,6 +215,30 @@ describe("TimelineLanes disclosure target", () => { act(() => view.root.unmount()); }); + // Two timelines on one page (a mini-timeline in a modal beside the main one) + // both minted `timeline-lanes-track-0`, so every caret's aria-controls + // resolved to whichever instance mounted first. + it("mints lane ids that do not collide with a second TimelineLanes on the page", () => { + const first = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); + const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); + + const idsFor = (host: HTMLElement) => + Array.from(host.querySelectorAll("button[aria-controls]")).map((caret) => + caret.getAttribute("aria-controls"), + ); + const firstIds = idsFor(first.host); + const secondIds = idsFor(second.host); + + expect(firstIds.length).toBeGreaterThan(0); + expect(firstIds.some((id) => secondIds.includes(id))).toBe(false); + // Still a legal CSS id selector: the aria-controls lookups above use `#id`. + for (const id of [...firstIds, ...secondIds]) { + expect(id).toMatch(/^[A-Za-z][\w-]*$/); + } + act(() => first.root.unmount()); + act(() => second.root.unmount()); + }); + // The passenger branch wraps [clip, lanes] in a transformed div that re-renders // on every pointer move. An unstable key there remounts the lanes and drops the // in-flight drag. diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 32e805631..58d857762 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -1,10 +1,11 @@ +import { useId } from "react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; -import { trackDisplayNumber } from "./timelineTrackDisplay"; +import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout"; @@ -89,6 +90,12 @@ export function TimelineLanes({ onRazorSplit, onRazorSplitAll, }: TimelineLanesProps) { + // Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal + // beside the main one) cannot both mint `...-track-0` and have every caret's + // aria-controls resolve to whichever mounted first. React's useId embeds + // colons, which are legal in an id and in aria-controls but need escaping in + // a CSS `#id` selector, so they come out here and the prefix stays plain. + const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); const toggleClipExpandedTracked = (key: string) => { @@ -137,7 +144,7 @@ export function TimelineLanes({ // the disclosure: the caret in the sticky header and the diamond lanes // on the canvas. Keyed by display row, not by `trackNum`, which is a // fractional sort key and would mint ids like `...-0.16666666666666666`. - const lanesId = `timeline-lanes-track-${row}`; + const lanesId = `${lanesIdPrefix}-track-${row}`; return (