feat(studio): instantPatch fast path in runCommit#1613
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at decff5d6. Wiring is clean — applyPreviewSync extraction makes the path very testable, and the 6-case applyPreviewSync matrix in useGsapScriptCommits.test.tsx is exactly the right shape. Race-fix classification: this is a PREEMPT for the value-only-drag class (the reload doesn't merely run faster, it doesn't run at all when the patch lands) with a clean NARROW fallback to soft-reload (same shape as today). Good. Couple of structural concerns.
Concerns
-
Sibling-asymmetry —
findUnsafeMutationValuesdoesn't guard the fast path.runCommitcallsfindUnsafeMutationValues(mutation)before the POST and bails with a user-facing toast on unsafe values. The instantPatch fast path runspatchRuntimeTweenInPlace(iframe, selector, change)againstoptions.instantPatch.change— notmutation.patchSet/patchKeyframesre-checkNumber.isFinitefor their own inputs (so a raw NaN can't slip through), but if the mutation carries unsafe values, the earlyunsafeFields.length > 0path throws/returns BEFORE the patch is attempted — so we're fine on that side. The asymmetry to watch is the other direction:instantPatch.change.propsis sourced independently from the mutation (the caller constructs both), so a buggy caller can ship a clean mutation + a malformed patch. Today both come from the samenewX/newYlocals, so it's tight. Worth a code comment incommitStaticGsapPositionnoting the two must stay in lockstep (or, more durable: deriveinstantPatch.change.propsfrom the just-builtmutationobject so they can't drift). -
Fast-path can write to the wrong tween (channel-blind resolution). Repeating from my #1612 review since it's the place that matters:
resolveRuntimeTween(iframe, selector, "set")returns the first zero-duration tween matching the selector — no channel match.commitStaticGsapPosition's second commit attachesinstantPatch: { kind:"set", props:{x,y} }; if the live runtime has a separatetl.set("#el",{rotation})shape, the patch can land on the rotation tween and force-writex/ychannels there. Source file is correct, runtime is inconsistent until next full reload. Either tightenresolveRuntimeTween(prefer tween whosevarsalready carries one of the requested channels), or document fast-path as merged-set-only and gate the caller. -
Coalesced x/y intermediate commit silently swallows preview state.
commitStaticGsapPositiondoes{x: skipReload}then{y: softReload + instantPatch}. The intermediate POST writes the newxserver-side but leaves the live tween untouched (no patch, no reload). If the second POST fails server-side, the intermediateupdate-property xis now persisted but the user's visual feedback never reflected it — and theinstantPatchblock inrunCommitis downstream of the throw, so no patch fires either. The final-coalesce contract assumes the second commit always lands; worth a comment, or attachinginstantPatchto both commits withskipReloadon the first (the patch is idempotent, so re-applying is safe).
Nits
commitStaticGsapRotation's update-property carries{rotation: newRotation}in instantPatch but the source mutation is{property: "rotation", value: newRotation}. Same value, two shapes — minor mismatch readability. (nit)applyPreviewSyncdoc comment says "Onfalse— or when noinstantPatchis supplied — fall back to the existing soft/full reload." Once #1605 lands and drops the→ full reloadescalation, this sentence is stale. (nit, fixed by #1605)
What I didn't verify
- Whether
__player.seek(currentTime)after an in-placevarsmutation actually re-paints synchronously enough that the user perceives the patch as "instant" vs. "next frame" — there's a render flush in there I trusted the helper's test to assert correctly via theonSeekcallback model. - The
serializerRefordering across the coalesced x/y pair vs other simultaneous edits to the same file — the existinggsap-file:${file}per-file serializer should chain them, but I didn't trace the interleaving for a same-filecommitStaticGsapPositioncolliding with acommitStaticGsapRotation.
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at decff5d6. Concur with @james-russo-rames-d-jusso — the wiring is clean, the applyPreviewSync extraction makes the path very testable, and the 6-case test matrix is exactly the right shape. But the channel-blind tween resolution from #1612 becomes user-reachable here, and the coalesced x/y intermediate-commit has a real failure mode worth pinning.
Verified at HEAD — instantPatch opt-in dispatch chain:
Every update-property site enumerated; only value-only (static set) commits opt in; structural branches correctly omit:
| Site | File:line | Mutation shape | Opt-in | Correct? |
|---|---|---|---|---|
commitStaticGsapPosition x (coalesced intermediate) |
gsapDragCommit.ts:354 |
update-property x, skipReload |
NO | ✓ |
commitStaticGsapPosition y (coalesced final) |
:359 |
update-property y, softReload |
YES {x,y} |
✓ (channel-blind risk, see below) |
commitStaticGsapPosition add (no existing) |
:374 |
add, softReload |
NO (structural) | ✓ |
commitStaticGsapRotation update |
:416 |
update-property rotation, softReload |
YES {rotation} |
✓ (channel-blind risk) |
commitStaticGsapRotation add |
:433 |
add, softReload |
NO (structural) | ✓ |
extendTweenAndAddKeyframe |
:142 |
extend keyframes | NO (structural) | ✓ |
commitKeyframedPosition |
:168 |
keyframe-edit | NO (PR body excludes) | ✓ |
commitFlatViaKeyframes (3 sites) |
:251, :282, :295 |
convert-to-keyframes/add-keyframe |
NO (structural) | ✓ |
commitGsapPositionFromDrag (3 sites) |
:594, :605, :616 |
structural | NO | ✓ |
useGsapPropertyDebounce.flushPendingPropertyEdit |
useGsapPropertyDebounce.ts:94 |
update-property, softReload |
NO | scope gap (below) |
Structural-branch silent-no-op risk within the drag commit graph: NONE. Every structural branch correctly omits instantPatch. Test at gsapDragCommit.test.ts:128 ("structural keyframe drag... sets no instantPatch") pins this.
Concur with @james-russo-rames-d-jusso's BLOCKERs:
-
Channel-blind runtime resolution makes the position fast-path land on the rotation
settween (or vice versa). Source-side writer discriminates (findPositionSetAnimation/findRotationSetAnimation);patchSetdoesn't. Position drag with{x,y}lands on a sibling rotationsettween → force-writesx/ychannels intovarsthat don't belong there. Source file correct, runtime inconsistent until next full reload (#1605 drops the full-reload escalation, so the inconsistency persists). Maps to band-aid pattern #2 + #4 as analyzed in my #1612 review; cheap fix is the channels-hint atresolveRuntimeTween. -
Coalesced x/y intermediate-commit silently swallows preview state on the first commit's failure.
commitStaticGsapPositiondoes{x: skipReload}then{y: softReload + instantPatch}. If the SECOND commit fails server-side, the persistedupdate-property xis stale on screen — theinstantPatchblock inrunCommitis downstream of the throw, no patch fires either. The final-coalesce contract assumes the second commit always lands. Either disclose explicitly OR attachinstantPatchto BOTH commits withskipReload: trueon the first (the patch is idempotent, so re-applying is safe). Band-aid pattern #3 (silent scope gap on coalescence contract). -
findUnsafeMutationValueslockstep gap.findUnsafeMutationValues(mutation)runs before POST; the instantPatch path runspatchRuntimeTweenInPlace(iframe, selector, change)againstoptions.instantPatch.change— independently sourced. Today both come from the samenewX/newYlocals so it's tight. Worth either (a) derivinginstantPatch.change.propsfrom the just-builtmutationobject so they can't drift, or (b) a code comment incommitStaticGsapPositionnoting the two must stay in lockstep. Durability-grade.
Net-new — drag-vs-panel asymmetry (NIT, scope-gap not band-aid):
useGsapPropertyDebounce.flushPendingPropertyEdit (panel-driven edits) produces a value-only update-property with softReload: true but does NOT carry instantPatch. Consistent with PR body's drag-scope framing, so not band-aid pattern #5 — but it leaves the property panel slower than drag for the same underlying operation. Worth either a follow-up PR or a sentence in the body acknowledging the panel path is unchanged this slice.
Concur with @james-russo-rames-d-jusso on nits:
applyPreviewSyncdoc comment "fall back to existing soft/full reload" is stale once #1605 drops the→ full reloadescalation. Update in this PR or #1605.commitStaticGsapRotation{rotation: newRotation}in instantPatch vs{property: "rotation", value: newRotation}in mutation — same value, two shapes — minor readability.
Verdict: BLOCK. Two hard request-changes from Rames I concur with; once channel-blindness + coalesced-pair failure mode close, this is clean wiring and the test matrix locks the contract. Race-fix classification PREEMPT (not NARROW) — strong shape.
Review by Via
A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on success the preview updates in place with NO reload (instant), on false it falls back to the existing soft reload. Extracts the preview-sync tail into a testable applyPreviewSync helper. No behavior change when instantPatch is absent.
…tPatch
Static-element position and rotation set commits now attach instantPatch{selector,
change:{kind:set}} so the drag updates in place with no reload. Structural ops (new
tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit
it and keep the soft reload — keyframe instant-patch needs object-form keyframe support
in patchRuntimeTweenInPlace (deferred).
…tion, patch both coalesced commits, wire onAsyncFailure
- commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual
update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated
values flow into the patch; can't drift).
- Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y},
so a second-POST failure still leaves the preview patched for what persisted.
- applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload);
per U4 the synchronous false still does NOT escalate.
- (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.)
dc79a2a to
2a0df60
Compare
decff5d to
ff2be9d
Compare

Summary
This PR wires the value-only
instantPatchfast path into the GSAP commit pipeline. When a drag only changes the values of an existing tween (a staticsetof position or rotation),runCommitnow patches that one tween directly in the preview's runtime timeline in place — no composition re-run, no soft reload, no iframe remount — so the dragged element is correct on screen instantly. If the in-place patch can't be confidently applied, it falls back to the existing soft-reload (then full-reload) path, so behavior is never worse than today.The
patchRuntimeTweenInPlacehelper landed in #1612; this PR connects it tocommitMutation/runCommitand routes the static position/rotation drags through it. Structural and keyframe edits deliberately do not opt in.What's changed
gsapScriptCommitTypes.tsAdds optional
instantPatch?: { selector: string; change: RuntimeTweenChange }toCommitMutationOptions. The doc comment spells out the contract: when present,runCommitfirst tries the in-place patch; on success the reload is skipped entirely (panels still refresh via cache invalidation); when it can't apply, fall back to soft/full reload. Structural edits omit it.useGsapScriptCommits.tsrunCommitinto a new pure, exported helperapplyPreviewSync(iframe, result, options, reloadPreview)(no React → directly unit-testable).options.instantPatchis set, callpatchRuntimeTweenInPlace(iframe, selector, change); ontruereturn early (element already correct, no reload); onfalse/ noinstantPatch, fall through to the existing path (soft-reload whensoftReload && result.scriptText, escalating toreloadPreview()if the soft reload fails, else fullreloadPreview()).runCommitdelegates toapplyPreviewSync(...)instead of inlining the branch; the priorappliedbookkeeping is removed;onCacheInvalidate()still fires in every case. ImportspatchRuntimeTweenInPlace.gsapDragCommit.tsRuntimeTweenChangeand addsinstantPatch?to thecommitMutationcallback options inGsapDragCommitCallbacks.commitStaticGsapPosition— the final commit of the coalesced x/y pair (theupdate-propertyfory, carryingsoftReload: true) also attachesinstantPatch: { selector, change: { kind: "set", props: { x, y } } }(the full{x, y}so the runtimetl.setis patched in one shot). The intermediatexcommit staysskipReload, no patch.commitStaticGsapRotation— the value-only rotationupdate-propertycommit attachesinstantPatch: { selector, change: { kind: "set", props: { rotation } } }.settween, keyframe conversion) are untouched and carry noinstantPatch.Why
patchRuntimeTweenInPlacealready knew how to mutate a single runtime tween, but nothing in the commit pipeline used it — every drag commit went through a soft (or full) reload. For the most common interaction (dragging a layer to reposition/rotate, which only changes an existingset's values), that reload is wasted work and adds visible latency.instantPatchlets a commit declare "this is a value-only change to this tween — try patching it directly";runCommithonors that, giving instant feedback while keeping the source write + cache invalidation intact. Thepatch → soft reload → full reloadfallback means anything structural or ambiguous degrades gracefully to exactly today's behavior.Testing
useGsapScriptCommits.test.tsx(new) — mockspatchRuntimeTweenInPlace+applySoftReload.applyPreviewSync(pure): six cases covering the full matrix (instant succeeds → no reload; instant fails → soft; instant+soft fail → full; and the three no-instantPatchcases matching today).runCommit(full hook): instant succeeds → persists (one fetch) + records + invalidates + no reload; instant fails → persists + soft-reload fallback; no instantPatch → identical to today.gsapDragCommit.test.ts—commitStaticGsapPosition(intermediate x isskipReloadno patch; final y carriessoftReload+ full{x,y}patch; adding a new set is structural, no patch);commitStaticGsapRotation(update carries{rotation}patch; add is structural);commitGsapPositionFromDragkeyframe drag is structural (noinstantPatch).gsapRuntimeBridge.test.ts—tryGsapDragInterceptforwardsinstantPatch { kind:"set", x, y }on the final commit when updating an existing static set (x no patch, final y the full{x,y}).Stack
Part of the GSAP keyframe/motion-path stack:
#1553 → #1554 → #1555 → #1607 → #1608 → #1609 → #1610 → #1611 → #1567 → #1612 → #1613 → #1605. On #1612. Builds independently; combined diff byte-identical to the originally-reviewed work.