fix(studio): switch keyframe ease modes optimistically - #2695
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
ad6c104 to
c9e12e8
Compare
899af39 to
37392fe
Compare
c9e12e8 to
b968aba
Compare
37392fe to
2375957
Compare
b968aba to
4762c45
Compare
2375957 to
2612be0
Compare
4762c45 to
bc1c49e
Compare
2612be0 to
1219dec
Compare
bc1c49e to
2cbd849
Compare
1219dec to
23a8103
Compare
2cbd849 to
2469850
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 2469850c.
The optimistic-display state machine at packages/studio/src/components/editor/EaseCurveSection.tsx:333-360 is neat: pendingEase = { source, value } where source is the parent-ease prop at commit time, and displayedEase = pendingEase?.source === ease ? pendingEase.value : ease auto-clears the moment the parent's ease prop changes (either to what we committed or to something else entirely). The [ease] effect at line 353 belt-and-braces this with an explicit setPendingEase(null) on any prop change. Two of the new tests pin the invariant well:
EaseCurveSection.test.tsx:311— "keeps an optimistic mode visible through its canonical prop round-trip" (parent echoes back the same string we committed → optimistic stays applied becausesource === easestill holds).EaseCurveSection.test.tsx:334— "replaces an optimistic mode when the canonical prop changes externally" (parent lands on something different → optimistic clears becausesource !== easenow).
And the AnimationCard.test.tsx:264 .each matrix covers Spring / Wiggle / Curve mode transitions end-to-end through onUpdateKeyframeEase — good coverage of the wiring, not just the local state.
Concerns
-
Rollback path assumes the parent always emits an
easechange.pendingEaseis only cleared when the[ease]effect fires. IfonCustomEaseCommit→onUpdateSegmentEase/onUpdateKeyframeEase→gsapCommitMutationfails silently or the mutation lands as a no-op (same string committed as already stored), the store'seaseprop never changes, the effect never re-runs, and the optimistic value sticks in the UI even though the authored source didn't change. The description says "preserving rollback behavior on failure", and the round-trip test covers the success echo, but I don't see a test where the commit fails and the display returns to the pre-click state. If the mutation path guarantees an ease-prop change on every commit outcome (success OR rejection), a comment naming that invariant oncommitEasewould close it out; if not, a rejection-path test or an explicit rollback (clearpendingEaseafteronCustomEaseCommitreturns/rejects) is what makes the rollback claim load-bearing. -
packages/studio/src/components/editor/holdEaseSeek.test.tsreaches across package boundaries and is out of scope for this PR. Theimport { installStudioCustomEase } from "../../../../core/src/runtime/customEase";bypasses@hyperframes/core's exports —installStudioCustomEaseis not re-exported frompackages/core/src/index.tsand there's no./customEase/./runtime/customEaseentry inpackages/core/package.json.installStudioCustomEasealready has coverage inside core atpackages/core/src/runtime/customEase.test.ts, and the new test's scope (GSAPparseEase("hold")+timeline.seek()) isn't referenced by the PR body. Either move the test topackages/core/src/runtime/(where the deep import goes away) or add a./customEasepackage.json export so this call site canimport from "@hyperframes/core/customEase".
What I didn't verify
- Multi-target segment commits (
onUpdateSegmentEasebulk-edit):pendingEaseis a per-EaseCurveSectionlocal state, and each colliding-target keyframe gets its own instance, so the optimistic display should be per-instance-correct — but I didn't trace whether a bulk commit's completion emits aneaseprop change on each mountedEaseCurveSectionin the same tick or whether one instance might race the other.
— Review by Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
R1 review — via
Grade: B+
Overall: CORRECT (non-blocking)
Thesis check: "Optimistic" here means the ease inspector paints the newly-selected mode locally the instant the user clicks, instead of waiting for ease prop to round-trip back through the mutation pipeline. The implementation matches: pendingEase = { source: <ease at commit time>, value: <predicted new ease> }, and displayedEase = pendingEase?.source === ease ? pendingEase.value : ease. Everything the inspector derives (mode, springBounce, wiggleConfig, curve, curvePath, showGraph, label, dropdown ease, param field) now reads displayedEase — so the mode-tab, glyph, and param field converge with the click even before the parent's gsapCommitMutation settles. The useEffect [ease] clears both draft and pendingEase when the parent lands (or diverges), which is the "canonical prop round-trip" contract the two new controlled tests pin down. That matches the title.
P0/P1 findings:
None.
P2/P3 findings:
-
[P2]
packages/studio/src/components/editor/EaseCurveSection.tsx:337-353— rapid double-click transient flicker. If the user clicks Spring then Wiggle before the parent processes the first commit, the sequence is:- Click Spring →
pendingEase = { source: X, value: SPRING }, display = SPRING, firesonCustomEaseCommit(SPRING). - Click Wiggle (still same render,
easeclosure = X) →pendingEase = { source: X, value: WIGGLE }, display = WIGGLE, firesonCustomEaseCommit(WIGGLE). - Parent lands commit 1 first →
easebecomes SPRING →pendingEase.source (X) !== ease (SPRING)sodisplayedEase = SPRING. Effect fires, clearspendingEase. - Parent lands commit 2 →
easebecomes WIGGLE, display = WIGGLE. - Visible sequence: WIGGLE → SPRING → WIGGLE. That's exactly the mid-motion flicker the fix is trying to eliminate, but only for a single commit in flight.
- Failure scenario:
handleUpdateSegmentEaseinuseDomEditSession.ts:463firesvoid gsapCommitMutation(...)per call — I couldn't confirm whether the parent coalesces vs. serializes rapid mutations to the same segment. If coalesced (last-write-wins), no flicker. If serial, the intermediate render is user-visible on a slow doc pipeline. - Mitigation is small: track the predicted terminal value instead of the source-at-click, e.g. hold
pendingEaseuntilease === pendingEase.value(rather thanease !== pendingEase.source). Or explicitly test rapid double-clicks and let ordering guide the design.
- Click Spring →
-
[P2]
packages/studio/src/components/editor/EaseCurveSection.tsx:355-360— fire-and-forget optimistic without a rollback channel. The PR body claims "preserving rollback behavior on failure," but I don't see any signal path from a failed commit back to this component.onCustomEaseCommitisvoid-called; if the parent'sgsapCommitMutationsilently drops or rejects (seeuseDomEditSession.ts:484—void gsapCommitMutation(...)),easeprop never changes,pendingEase.source === easestays true forever, anddisplayedEase = pendingEase.valuecontinues to lie about persistence.- Failure scenario: any transient failure inside
gsapCommitMutationthat doesn't push a correctiveeaseback through props leaves the user believing the mode switch persisted — but reloading (or serializing the doc) shows the old ease. There's no toast, no revert, no error boundary here. - Fix options: (1) verify upstream that the mutation pipeline always pushes an
easechange on either success or failure, and note that assumption in the comment abovependingEase; (2) add a timeout that clearspendingEaseafter N ms ifeasehasn't converged; (3) return a promise fromonCustomEaseCommitso this component can await + roll back on rejection. - This is not a regression (the pre-fix
ease === sourcecompare would also silently drop), but the PR body asserts rollback is preserved — the code doesn't demonstrate that. Suggest either wiring failure back or softening the body language.
- Failure scenario: any transient failure inside
-
[P3]
packages/studio/src/components/editor/holdEaseSeek.test.ts:1— cross-package relative import bypassing the export contract.import { installStudioCustomEase } from "../../../../core/src/runtime/customEase";is the only file inpackages/studio/srcthat reaches intopackages/core/srcwith a relative path. Everything else uses@hyperframes/core/<subpath>(e.g.@hyperframes/core/gsap-parser,@hyperframes/core/spring-ease). Verified viarg -n 'from "\.\./\.\./\.\./\.\./core/'returning empty otherwise.packages/core/package.jsondoesn't export./runtime/custom-ease(grep ofcustomEase|custom-easereturns none), which is why the relative path exists.- Failure scenario: on a real build,
packages/corepublishes only what itsexportsmap declares. A subpath consumer via a deep relative path may work in-workspace (tsc resolves through the workspace filesystem) but breaks external@hyperframes/coreconsumers and violates the package-subpaths check that the CI'scheck:package-subpathsscript enforces. Also creates a maintenance trap: renamingpackages/core/src/runtime/customEase.tssilently breaks studio tests. - Fix: either (a) add a
"./runtime/custom-ease"subpath topackages/core/package.jsonand import via@hyperframes/core/runtime/custom-ease, or (b) move the test intopackages/core/src/runtime/wherecustomEase.test.tsalready lives (that file already covershold(0.5) === 0at line 27; the seek-with-real-gsap variant would live naturally next to it). Note core lacks a directgsapdep — option (a) is the cleaner fix.
- Failure scenario: on a real build,
-
[P3]
packages/studio/src/components/editor/holdEaseSeek.test.ts— scope creep vs PR title/body. The PR title/description scope is "switch keyframe ease modes optimistically" and "cover Curve, Spring, and Wiggle transitions."holdEaseSeek.test.tscovers hold-ease seek semantics through a real gsap timeline, unrelated to mode-switch optimism. Consider splitting.
Nits:
packages/studio/src/components/editor/EaseCurveSection.tsx:393-401— same-string commit no longer clearsdraft. The oldhandlePointerUphadqueueMicrotask(() => setDraft(null))which cleareddrafteven if the drag ended on a bit-identical ease string (rare, but possible when the drag returns to origin). The new code relies onuseEffect [ease], which won't fire wheneasestring is unchanged, sodraftpersists until the next drag or mode switch. Visually indistinguishable (draft matches ease-derived curve), but state lingers and diverges from prior behavior. Worth an explicit test or a note in the effect comment.packages/studio/src/components/editor/EaseCurveSection.tsx:365-368— the two-line comment introduced abovecurvePathis nice; the trailing comment on line ~371 explainingcurve !== nullis what keeps Hold handle-free reads a little stranded (it describesshowHandleswhich is already terse). Consider moving next toshowHandles.packages/studio/src/components/editor/holdEaseSeek.test.ts:2—import { gsap } from "gsap"in studio tests: fine because studio depends ongsap, but the test's real target is core runtime. Reinforces the P3 above about test placement.
Positive callouts:
renderControlledSection+ parametrizedit.eachinAnimationCard.test.tsx:263cover the three-mode transition matrix with a live keyframe animation, canonical-prop round-trip convergence (EaseCurveSection.test.tsx:308-330), and external replacement while an optimistic mode is pending (EaseCurveSection.test.tsx:332-355). Both round-trip AND divergence lanes exercised — solid coverage of the fix's semantics.- The
pendingEase.source === easegate is a compact way to represent "our prediction is still alive" without commit tokens/IDs. - The
useEffect [ease]clears bothdraftandpendingEasetogether — the two locals share a single "external truth changed" signal, which prevents them getting out of sync. - Comment at
EaseCurveSection.tsx:365calls out why the graph derives fromdisplayedEase— future readers will thank you. - Delegating the preset dropdown and param field to
commitEase(rather than rawonCustomEaseCommit) means every commit path — mode toggle, preset click, param field entry, curve drag, keyboard nudge — goes through the same optimistic gate. No asymmetric paint holes.
Race analysis: Traced three race paths. (a) Single click → commit → round-trip. pendingEase shows locally; when ease prop lands on either the predicted value or any other value, displayedEase picks up ease directly, then the effect clears pendingEase. No inter-render tearing because the fallback (ease) is always defined. Clean. (b) Two rapid mode clicks with serial parent commits. Intermediate-value flicker as described in the P2 above. If gsapCommitMutation coalesces the second write over the first, no flicker; if it serializes, the display flips through the intermediate before landing. (c) External change (undo/redo) mid-flight. pendingEase.source !== new ease, so displayedEase immediately reflects the external value; the effect then clears pendingEase. The pending write still fires and will land later — user transiently sees the undone value, then the pending lands. Acceptable behavior for this class of editor.
Sweep-fix breadth: Verified via rg -n "data-ease-mode" and rg -n 'role="radiogroup"' packages/studio/src/components/editor — the data-ease-mode toggle pattern is unique to EaseCurveSection. No sibling surface has the same "click a mode tab and see the mutation not stick" bug that this fix addresses. No cross-surface inconsistency introduced.
Standards-lens mechanical:
- Empty-count OK — N/A. Pass.
- Error boundary — leaf inspector component; parent boundary owns. Pass.
- Disposal / cleanup — no new subscriptions, listeners, or refs. Pass.
- Telemetry — inherits
trackStudioSegmentEaseEditatAnimationCard.tsx:315; the new commit path fires through the same callback. Pass. - Bare
as T— no new casts in source. Test usesmock.lastCall![0]non-null (pre-existing pattern in this test file). Pass. - Non-null
!— only in tests. Pass. - Untyped catch
.message— N/A. Pass.
CI note: Preflight (lint + format) is failing on packages/studio/src/hooks/useDomEditSession.ts (oxfmt), but that file is not in this PR's diff. The failure comes from a downstack PR in the #2692→#2695 stack; preview-regression then cascades because PREVIEW_PARITY_RESULT: skipped. Fix belongs downstack.
— Review by Via
23a8103 to
0165b3a
Compare
2469850 to
cbd22db
Compare
|
All three are fixed. Rollback. The commit path is void end to end, so nothing downstream can report a rejection. Instead of implying a signal that does not exist, the optimistic value is now time-bounded: if the round trip does not arrive within 2s the panel falls back to the committed Double-click race. The section now keeps a queue of every value it committed and has not yet seen come back. When Cross-package import. |
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Delta-reviewed 2469850c..cbd22db3.
Both prior concerns cleanly resolved, and the rollback fix in particular went beyond what I asked for by also solving a race I hadn't named (rapid mode-switching flicker):
-
Rollback:
pendingEaseshape simplified from{source, value}to a plain string, and correctness is now carried by two orthogonal mechanisms:- An
inFlightEasesRefqueue atpackages/studio/src/components/editor/EaseCurveSection.tsx:342that records every commit and matches arrivingeaseprops against it.[ease]effect at line 355 splices settled commits off the queue and only clearspendingEasewhen the queue drains — so an older commit's echo cannot repaint over a newer optimistic value (the "wiggle → spring → wiggle" flicker), and an out-of-band ease change (not in the queue) still wins immediately. Test atEaseCurveSection.test.tsx:347pins the double-switch case. - A
PENDING_EASE_TIMEOUT_MS = 2000fallback at line 378-388 that force-clears the optimistic value if the round-trip never arrives. Test atEaseCurveSection.test.tsx:369usesvi.useFakeTimers()+advanceTimersByTime(2000)to verify the fallback fires. The docblock ("Long enough for a normal write-reparse-rerender round trip, short enough that a dropped write self-corrects while the author is still looking at the panel") names the trade-off honestly.
- An
-
Package boundary:
./runtime/custom-easeexport added topackages/core/package.json:122,packages/core/package-subpaths.json:113, andpackages/core/tsconfig.json:17. The test's import is now@hyperframes/core/runtime/custom-ease— proper package-boundary crossing — and the new comment explains why the seek test lives in studio (real gsap, not the parseEase stub the core resolver test uses) rather than the intuitive "move it to core" alternative. Fair justification.
The publishKeyframeCache primitive from #2694 rides in as expected; the delta on that surface here is identical to what I signed off on there.
What I didn't verify
- The 2s timeout under a slow real-world commit path. In studio-local editing the round trip is well under 2s, but if this component ever gets rendered against a remote-backed commit (e.g. shared-session editing), 2s could false-positive rollback while a legitimate commit is still in flight. Worth watching if the timeout ever needs a policy override — for now the constant + docblock is the right shape.
- Multi-target segment commits (the bulk-edit path from #2693): each
EaseCurveSectioninstance owns its owninFlightEasesRef, so the queues are independent, which I believe is correct — but I didn't trace whether a bulk commit's completion emits aneaseprop change on each mounted section in the same tick.
— Review by Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
R2 review — via
HEAD verified: cbd22db32530bfe30ea53a2ff68284be07fce9cb
Grade: A- (was B+)
Overall: APPROVE
R1 findings delta:
-
[P2 fire-and-forget without rollback] — RESOLVED.
packages/studio/src/components/editor/EaseCurveSection.tsx:329,389-396introduces a 2sPENDING_EASE_TIMEOUT_MSfallback: whenpendingEaseis set, an effect armssetTimeoutthat clears bothinFlightEasesRefandpendingEaseif the committedeaseprop never comes back. That's option (2) from my R1 (time-bounded clear) — it makes the "preserving rollback behavior on failure" claim in the PR body load-bearing without demanding a rejection channel throughonCustomEaseCommit. Locked byEaseCurveSection.test.tsx:353-378—falls back to the committed ease when the commit never round-tripsusesvi.useFakeTimers()+vi.advanceTimersByTime(2000)and assertsaria-checkedflips fromspringback tocurve. Comment on lines 384-388 names the invariant explicitly. Load-bearing test, live rollback path. Closes both the Via and the convergent Rames concern. -
[P2 double-click flicker (WIGGLE→SPRING→WIGGLE)] — RESOLVED.
EaseCurveSection.tsx:346,365-382replaces the "source-at-click vs current-ease" gate with a proper FIFO queue:inFlightEasesRefrecords every value the section committed, oldest first. Wheneaselands,indexOf(ease)locates the settled commit andsplice(0, landed + 1)retires it plus everything older. If the queue is non-empty,pendingEasestays — so an older commit echoing back mid-flight does NOT repaint the intermediate. Exactly the mitigation I described in R1 ("track the predicted terminal value") generalized to the multi-write case. Locked byEaseCurveSection.test.tsx:331-350—ignores an older in-flight commit arriving after a newer switchclicks Spring then Wiggle then rolls backeasetospring(0.42)and assertswigglestays checked. Externally-driven divergence still short-circuits:indexOf < 0clears the queue and dropspendingEase(lines 369-374), verified byEaseCurveSection.test.tsx:401-418. -
[P3 4-level relative import bypassing export contract] — RESOLVED.
packages/core/package-subpaths.json:113-118,packages/core/package.json:122-128+362-365, andpackages/core/tsconfig.json:18add a./runtime/custom-easesubpath (source + bun + node + dist + types).holdEaseSeek.test.ts:4now imports via@hyperframes/core/runtime/custom-easeinstead of the 4-level relative path. VerifiedinstallStudioCustomEaseis exported atpackages/core/src/runtime/customEase.ts:91. Package-subpaths CI check has a valid target. This is the R1-recommended fix (a) rather than moving the test, which is fine — studio owns thegsapdep and this is the studio adapter surface. -
[P3 test out-of-scope vs PR title/body] — UNCHANGED.
holdEaseSeek.test.tsstill covershold-ease seek semantics through a real gsap timeline, not "mode switch." Given (a) the boundary breach is now fixed and (b) this is P3, no longer worth blocking — it's a bundled seek regression net for theholdadapter that happens to ride this stack. Author's call. -
[Rames convergent — rollback path]: RESOLVED, convergent with Via. Same finding, same fix satisfies both.
-
[Rames convergent — cross-package boundary]: RESOLVED, convergent with Via. Same finding, same fix satisfies both.
Fresh-pass findings on the new queue + timeout code:
-
[P3 nit — draft lingers on 2s-timeout drag failure]
EaseCurveSection.tsx:389-396. The pending timeout clearspendingEaseandinFlightEasesRefbut notdraft. If a curve-drag commit is dropped and the 2s falls through,curvePath/showGraphrevert to the committedease(viadisplayedEase = pendingEase ?? ease), butactiveTuple = draft ?? curvestill uses the stale draft tuple for the handle positions — so the graph shape reverts while the P1/P2 handle dots stay on the dragged coordinates. Corner case (curve drag + parent drop + 2s+ before another interaction), and the next mouse-down re-seedsdraft, so it self-heals. Consider also clearingdraftinside the timeout for symmetry. Not blocking. -
[P3 nit — draft not cleared on same-string commit] carried from R1. Unchanged in this pass; already flagged, still visually equivalent since curve equals draft on a same-string commit. Kept as a note, not new.
-
[Positive — queue design handles same-value repeats correctly]
inFlightEasesRef.indexOf(ease)returns the FIRST occurrence, andsplice(0, landed + 1)retires everything up to and including it. So rapid Spring→Wiggle→Spring or Spring→Spring double-clicks resolve cleanly (traced Spring→Wiggle→Spring: after each landing, the queue-oldest-first invariant holds,pendingEasenever repaints an intermediate). No race pathology introduced. -
[Positive — multi-target
EaseCurveSectioninstances stay independent]inFlightEasesRefis per-instance state, and each colliding-target commit flows through a distinctEaseCurveSection. Rames flagged this as unverified in R1; the queue approach makes intra-instance ordering correct without cross-instance coordination. Bulk-commit success or failure surfaces the same on every mounted instance since they each hold their own optimistic queue. -
[Positive —
useEffect [ease]and[pendingEase]cleanup discipline] Timeout is properly cleared via return handle. Ref does not need cleanup (dies with component). No listeners, no subscriptions.
Positive callouts:
- Queue-based
inFlightEasesRefis the right generalization of the R1 mitigation. NamedinFlightwith the oldest-first comment on line 343-345 makes the invariant scannable. - The two purpose-built comments on lines 324-328 (
PENDING_EASE_TIMEOUT_MS) and 384-388 (fire-and-forget rationale) explain why the fallback timer exists — future readers who wonder "can we just await the promise?" will find their answer in-file. - Three new tests directly pin the three semantic invariants (canonical round-trip, queue-order flicker suppression, time-bounded rollback). Coverage is unambiguous and pins the R1 concerns.
packages/core/package-subpaths.json+package.json+tsconfig.jsonco-updated in the same commit — the subpath export is complete (source + runtime + types + dist target). No maintenance trap.openSegment(host, label)helper factored out ofAnimationCard.test.tsxcleans up two call sites and reads more clearly.
Standards-lens (mechanical):
- Empty-count OK — N/A. Pass.
- Error boundary — leaf component. Parent owns. Pass.
- Disposal / cleanup — timeout
clearTimeoutreturned. Pass. - Telemetry — inherits
trackStudioSegmentEaseEditviacommitEase→onCustomEaseCommit. Pass. - Bare
as T— no new casts. Pass. - Non-null
!— test-only, pre-existing. Pass. - Untyped catch — N/A. Pass.
Merge readiness: Preflight (lint + format) is now GREEN on this HEAD — the downstack oxfmt issue I flagged in R1 has been cleared by the stack rebase. Regression shards still IN_PROGRESS; Graphite mergeability check IN_PROGRESS. Green all P2/P3, no new P1, PR is ready to land once required regressions finish.
— Review by Via
cbd22db to
ea7b955
Compare
0165b3a to
cec792f
Compare
The base branch was changed.
Right-clicking a motionPath waypoint in the preview overlay opened Chrome's own context menu on top of the editor: the handler returned before preventDefault for every node that was not an x/y keyframe. Both node kinds now open Studio's menu. A waypoint has no percentage of its own, so Move to Playhead is hidden and Delete acts on the path index, matching the hover x badge; Delete is withheld entirely on a two-anchor arc, where the writer refuses the removal and the entry would silently do nothing.
An expanded sub-composition child row is built from a manifest clip, which carries none of the host element's attributes, so data-hidden never reached it. The eye on that row therefore always reported the element visible: the first click hid it, and every click after wrote data-hidden again instead of removing it. The element could not be shown again, not even after a reload, because the attribute was already in the source. The flat store element for the same child is built with its host element, so the child row inherits hidden, timelineLocked and timelineRole from it.
f30acf1 to
5dad523
Compare
…ine-c-ease-mode-switch-v2 fix(studio): switch keyframe ease modes optimistically

What
Three Studio timeline fixes, verified live in the preview server:
Why
data-hiddennever reached it. The eye on that row always reported the element visible: the first click hid it, and every click after wrotedata-hiddenagain instead of removing it. The element could not be shown again, not even after a reload, because the attribute was already in the source.How
hidden,timelineLockedandtimelineRoleon an expanded child from the flat store element for the same child, which is built with its host element.This is C4 and the tip of the independent Family C draft Graphite stack.
Test plan
Validated on this head with the full Studio suite (3,122 passed, 0 failed, 18 todos), package typechecks, oxfmt, oxlint, file-size gates, and Fallow with zero introduced findings. Fixes 2 and 3 were reproduced and re-verified by hand in the preview server against a keyframe/motion-path project.
Not covered here: the remaining families D through G still stack above this branch, and the slow save round trip observed locally when toggling visibility is unrelated to these changes.