Repository navigation
feat(studio): coordinate logical timeline focus - #2714
miguel-heygen wants to merge 4 commits into
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
e2856be to
8b6adc4
Compare
11d12c1 to
e1d4a4c
Compare
8b6adc4 to
5768986
Compare
e1d4a4c to
228e426
Compare
5768986 to
b4c62af
Compare
228e426 to
1716f68
Compare
c695707 to
e8f3f57
Compare
1716f68 to
2260abb
Compare
e8f3f57 to
199f448
Compare
2260abb to
1dfe7d8
Compare
199f448 to
2328b7b
Compare
1dfe7d8 to
c4c95f4
Compare
2328b7b to
a6733da
Compare
c8e7f3b to
3869327
Compare
a6733da to
f752a61
Compare
f752a61 to
b75399c
Compare
3869327 to
0c5a3e1
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
R1 review — coordinate logical timeline focus @ b75399c782
Verdict: APPROVE. Family F next slice is coherent: the reveal/focus split is folded into a single coordinator keyed by a logical-model ID, DOM nodes advertise their target via data-timeline-focus-id, and the deletion-fallback chain (resolveTimelineFocusFallback) is reused instead of duplicated. The scope guard (projectId + sessionEpoch) inside isCurrentRequest and beginTimelineSession clearing focus in createTimelineResetState() close the project/session-change axis. Deferred parity confirmed: useTimelineRevealClip.{ts,test.tsx} deleted, no remaining callers of requestClipReveal at HEAD, and both sidebar entry points (AssetCard, AudioRow) migrated.
Axis 1 — Focus stability across virtualization unmount/remount
- Focus is stored in the logical model (
timelineFocus.id), not indocument.activeElement. Remount consultslogicalRows+data-timeline-focus-id— no reliance on DOM identity across unmount. - Effect runs after commit (
useEffect, not layout); reveal-then-focus ordering preserved (scrollToTargetbeforefocusElementinuseTimelineFocusCoordinator.ts:130-146). - Roving-tabindex invariant holds:
TimelineTrackRow.tsx:53,TimelineClip.tsx:93, andTimelineTrackHeader.tsx:225all settabIndex={-1}— no candidate advertisestabIndex={0}, so.focus({preventScroll: true})is the only path in. - Between reveal-scroll and focus, if virtualization unmounts the target,
focusElementreturns false andappliedRefstays unset → the effect fires again next render. Self-healing.
Axis 2 — Reveal/focus handoff via the logical model
- Model-first resolve →
locateTimelineLogicalTargeton the current row set; if absent,resolveTimelineFocusFallback(previousRows, nextRows, id)walks item-backward → forward → surviving-row → parent → row-index. Result is rewritten viarequestTimelineFocus(fallback.id)(useTimelineFocusCoordinator.ts:114-117); the two-tick loop terminates once resolution matches the new request id. - Offscreen target: coordinator computes scroll geometry from the model (element index →
rowGeometry.getRowTop) without needing the target in the DOM. Scroll is issued, mount follows on the next render, focus lands then. - Scroll direct-assign (no
behavior: 'smooth') — no focus-vs-settle race.
Axis 3 — Regression tests
useTimelineFocusCoordinator.test.tsxcovers: pin-then-mount, latest-request-wins, stale-scope, and parent-row fallback on delete — all four assert behavior, not internals.Timeline.virtualization.test.tsx:288-296drives the full store flow (requestTimelineFocus(timelineClipFocusId(...))) and assertsdocument.activeElement === focusedClipafter remount, i.e. real focus lands post-virtualization, not just a flag.playerStore.test.tsexercises project rebinding viabeginTimelineSession("project-b")and assertstimelineFocus === null— session-change tear-down verified.
Nit
focusElement(useTimelineFocusCoordinator.ts:33-45) enumerates every[data-timeline-focus-id]under the scroll container and.find()s by dataset value. Fine at current row counts, but for large timelinesquerySelectorwithCSS.escapeon the attribute value would drop it from O(n) to O(1) — same pattern the olduseTimelineRevealClipused. Optional follow-up, not blocking.
— Review by Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at b75399c7.
Substantial refactor — 666+/546- across 24 files replacing the old useTimelineRevealClip clip-only reveal path (180 lines + 139-line test) with a single useTimelineFocusCoordinator that unifies logical target resolution, deterministic fallback, two-axis reveal, and DOM focus. Store slice reshapes accordingly (clipRevealRequest → timelineFocus with projectId/sessionEpoch/nonce), sidebar callsites migrated (requestClipReveal(id) → requestTimelineFocus(timelineClipFocusId(id))), and TimelineTrackRow / TimelineTrackHeader / TimelineClip all gain data-timeline-focus-id for the coordinator's DOM lookup. Model-first design: mounting is a consequence of the returned pins, not a precondition to focus.
Three inline concerns, all 🟡 — none blocking.
Verified end-to-end:
- ✅ Wire symmetry: sidebar's
requestTimelineFocus(timelineClipFocusId(clipKey))uses the SAME identity generator thatTimelineClip.tsx:93uses to emitdata-timeline-focus-id.focusElementfinds the clip via that attribute. No two-side-of-the-wire drift. - ✅ Stale-scope defense:
TimelineFocusRequestcarriesprojectId+sessionEpoch.isCurrentRequestguard filters mid-flight requests against the current scope. Test atuseTimelineFocusCoordinator.test.tsx:115-127proves external DOM targets in wrong scope are never focused. - ✅ Nonce idempotency:
clearTimelineFocus(nonce)is a compare-and-clear (s.timelineFocus?.nonce === nonce ? {timelineFocus: null} : s). A stale clear over a newer request is a no-op. Test atplayerStore.test.ts:521-524proves. - ✅ Fallback rewrite: when locate misses and fallback resolves to a different id, coordinator dispatches
requestTimelineFocus(fallbackId)which bumps nonce; next render'sresolvedRef.current?.nonce !== request.noncetriggers reset. One hop, converges. Test at:129-137verifies parent-row fallback when a clip disappears. - ✅ Cross-instance safety:
container.querySelectorAllscoped toscrollRef.current.data-timeline-focus-idvalues may collide across instances (same clip id), but the query never leaves its own container. No aria-owns-style DOM-invariant violation (that's #2713's concern, already resolved). - ✅ Two-axis reveal:
computeRevealScrollhandles sticky-header offsets (stickyLeft: contentOrigin,stickyTop: RULER_H). For row-only targets,allowHorizontal: allowHorizontal && left !== nullcorrectly disables horizontal scroll. - ✅
logicalRow.elementIdadded to the row model with cardinality-correct:nullfor track rows,activeIdfor property rows. Coordinator'spinnedElementIdderivation from a row target usesresolution.row.elementId ?? undefined— correct for both. - ✅ Model consolidation:
useTimelineLogicalRowswas duplicated acrossTimeline.tsxandTimelineLanes.tsx; now single-source at Timeline (viauseTimelineLogicalFocus) and passed down as a prop.logicalRowsByTrackderived locally in TimelineLanes via a targeteduseMemo([logicalRows]). No double-computation.
PR-body promise audit:
- ✅ "One coordinator resolves the logical target, chooses a stable fallback when needed" —
useTimelineFocusCoordinator+resolveTimelineFocusFallback. - ✅ "Pins the relevant row and clip" —
focusedRowKey→useTimelineRowVirtualization.pinnedRowKeys;pinnedElementId→useTimelineClipRenderWindow.pinnedClipIdentities. - ✅ "Reveals it on both axes" —
scrollToTargetviacomputeRevealScroll,allowHorizontalrespected. - ✅ "Applies DOM focus once the target mounts" —
focusElementwithdata-reveal-highlightmarker,.focus({preventScroll: true}), activeElement re-check for verify-then-highlight semantics. - ✅ "Replaces the older clip-only reveal path" —
useTimelineRevealClip.ts+.test.tsxfully deleted.
Nits (body-only):
- Coverage gap — depth-2+ fallback chains. Test at
useTimelineFocusCoordinator.test.tsx:129-137covers "focused clip disappears → fallback to parent row." Doesn't cover "focused clip disappears AND parent row also disappears" (e.g., track removed while a clip was focused). The underlyingresolveTimelineFocusFallbackchain handles this per #2712's tests, but the coordinator's wiring ofrequestTimelineFocus(fallbackId)isn't exercised for the deeper case. resolvedRef.current.id = fallback.idmutation during render atuseTimelineFocusCoordinator.ts:144— legal per React docs (refs aren't state), and stable under StrictMode double-invocation because thenonce !== request.noncecheck is the memoization gate. Non-obvious pattern — worth aponytail:marker per[[project_hf_ponytail_comment_idiom]]naming why the ref is reassigned mid-render (memo cache keyed on request nonce, not state).useTimelineLogicalRowsstill carries 7 store deps — a concern I raised on #2713 R1 (🟡) still stands, though it's out-of-scope for this PR. The wiring PR that closes the focus loop end-to-end (or a follow-up perf pass) should verify each upstream selector returns a stable ref; unmemoized Map/Set/Array construction on any of those seven selectors would rebuild the model every render and cascade through the coordinator'suseLayoutEffect.data-timeline-focus-idattribute contract — a new DOM attribute contract now exists (coordinator-side query key). If the team ever adds another focus-adjacent mechanism, they'll want to check for name collision. Consider apackages/studio/src/player/components/README.md(or CLAUDE.md entry) naming the attribute as reserved for the focus coordinator.focusElementremove-highlightblurlistener at:62-64uses{once: true}. If the element is programmatically re-focused later (e.g., user tabs away and back), the highlight already disappeared on the first blur — no restore. Semantically correct for "one-shot reveal highlight," worth naming.
Clean refactor. Ready from my side, leaving as COMMENTED.
b75399c to
9befa2e
Compare
0c5a3e1 to
4155482
Compare
9befa2e to
21b2c91
Compare
|
Review follow-ups folded into exact head |
vanceingalls
left a comment
There was a problem hiding this comment.
Delta re-verify at 21b2c9149 — approving (was already approved at b75399c782, this stamp confirms the delta).
Genuine code drift (not rebase noise) — Miguel addressed rames-d-jusso's 3 nits:
useTimelineFocusCoordinator.ts—resolutionwrapped inuseMemokeyed on[logicalRows, projectId, request, sessionEpoch], so the apply-focus effect no longer fires on every render;focusElementswapped from O(N).find()to O(1)container.querySelectorwithCSS.escape(targetId)(this is the exact improvement I flagged as a nit in R1).useTimelineRowVirtualization.ts— removed the nativefocusin/focusoutuseEffect(which stuck to the dead scroll node on remount); now returnstimelineFocusProps={ onFocus, onBlur }viauseCallback, spread onto the scroll container inTimeline.tsxso React's synthetic-event system handles remounts.TimelineLanes.tsx— droppedaria-multiselectable="true", addedaria-colcount={2}/aria-colindex={2}(single-select widget correctly labeled).
Prior R1 findings state — all still hold:
- Focus lives in
timelineFocus.id;TimelineTrackRow/TimelineClip/PropertyGroupHeaderRowstilltabIndex={-1}; deletion fallback chain preserved (now insideuseMemo, semantics unchanged); reveal-then-focus ordering and directscrollLeftassign intact;useTimelineRevealClip.{ts,test.tsx}remain deleted with no callers.
Test coverage strengthened:
- New
"does not retry an unchanged unmounted target on an unrelated render"(spiesscroll.querySelectorto prove memo elides redundant work). - New
"persists the next surviving row when both a clip and its parent track disappear"(deletion fallback → parent-row → surviving-row). - New
Timeline.virtualization.test.tsxcase"keeps focus pinning active after the scroll viewport remounts"— directly exercises the fix for the stale-listener regression.
Family F invariants preserved: element-identity keying, deterministic tie-breaks, deletion fallback, no non-component exports from .tsx, no useEffect state-syncing (the removed effect was DOM-subscription; its replacement is pure synthetic events).
Memo safety: mutation of resolvedRef.current inside useMemo is idempotent under Strict-Mode double-invocation (nonce-keyed early-continue), and previousRowsRef.current is still populated in the useLayoutEffect that runs after commit — fallback chain still sees the prior-render rows.
CI: base is stack branch codex/studio-timeline-f-treegrid-v2 — 7 required checks NOT_RUN (path/base filter, expected); regression SUCCESS. Land-ready via Graphite retarget to main.
— Review by Via
The base branch was changed.
|
Superseded by #3031, which landed this reviewed Family F content on main through the consolidated tip. |

What
Coordinate one logical timeline focus target across virtualization, scrolling, selection, and remounting.
Why
A focused row or clip can be outside the mounted viewport. Focus requests need to survive that gap instead of racing an element that does not yet exist in the DOM.
How
One coordinator resolves the logical target, chooses a stable fallback when needed, pins the relevant row and clip, reveals it on both axes, and applies DOM focus once the target mounts. This replaces the older clip-only reveal path.
This PR targets #2713 and remains one focused commit.
Test plan
The exact PR delta passes 43 focused focus-coordinator, store, and virtualization tests plus Studio typecheck, Fallow, and the file-size gate.