@@ -10,7 +10,7 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
1010import { roundTo3 } from "../utils/rounding" ;
1111import { computeElementPercentage } from "./gsapShared" ;
1212import { computeDraggedGsapPosition } from "./draggedGsapPosition" ;
13- import type { RuntimeTweenChange } from "./gsapRuntimePatch" ;
13+ import type { RuntimeTweenChange , SetPatchProps } from "./gsapRuntimePatch" ;
1414export interface GsapDragCommitCallbacks {
1515 commitMutation : (
1616 selection : DomEditSelection ,
@@ -381,6 +381,37 @@ async function commitFlatViaKeyframes(
381381// without importing the GSAP commit graph (store/runtime/core).
382382export { computeDraggedGsapPosition } ;
383383
384+ /** The shape of an `update-property` mutation a static-set nudge POSTs. */
385+ interface UpdatePropertyMutation {
386+ type : "update-property" ;
387+ animationId : string ;
388+ property : string ;
389+ value : number ;
390+ }
391+
392+ /**
393+ * Build the `instantPatch` for a value-only `tl.set` from the SAME
394+ * `update-property` mutation(s) that are POSTed — so the patch can never carry a
395+ * value the source write didn't (one source of truth). Each mutation contributes
396+ * its `{property: value}` channel to the patch's props.
397+ */
398+ function setPatchFromUpdateProperties (
399+ selector : string ,
400+ mutations : UpdatePropertyMutation [ ] ,
401+ ) : { selector : string ; change : RuntimeTweenChange } {
402+ const props : SetPatchProps = { } ;
403+ for ( const m of mutations ) props [ m . property as keyof SetPatchProps ] = m . value ;
404+ return { selector, change : { kind : "set" , props } } ;
405+ }
406+
407+ /** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
408+ function setPatchFromUpdateProperty (
409+ selector : string ,
410+ mutation : UpdatePropertyMutation ,
411+ ) : { selector : string ; change : RuntimeTweenChange } {
412+ return setPatchFromUpdateProperties ( selector , [ mutation ] ) ;
413+ }
414+
384415/**
385416 * Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
386417 * with no duration. This is what a static-element nudge writes/updates.
@@ -420,24 +451,41 @@ export async function commitStaticGsapPosition(
420451 // Update in place — two single-property mutations (the API updates one prop
421452 // per call). Coalesce them and reload only after the second lands.
422453 const coalesceKey = `gsap:set-nudge:${ existingSet . id } ` ;
423- await callbacks . commitMutation (
424- selection ,
425- { type : "update-property" , animationId : existingSet . id , property : "x" , value : newX } ,
426- { label : "Move layer" , skipReload : true , coalesceKey } ,
427- ) ;
428- await callbacks . commitMutation (
429- selection ,
430- { type : "update-property" , animationId : existingSet . id , property : "y" , value : newY } ,
431- {
432- label : "Move layer" ,
433- softReload : true ,
434- coalesceKey,
435- // Final commit of the coalesced x/y pair: carry the full {x,y} so the
436- // runtime `tl.set` is patched in place instantly (skips the soft reload
437- // when the helper can confidently apply it).
438- instantPatch : { selector, change : { kind : "set" , props : { x : newX , y : newY } } } ,
439- } ,
440- ) ;
454+ // Build each mutation FIRST, then derive its instantPatch from the SAME
455+ // object that's POSTed — so a future caller can't ship a clean mutation with
456+ // a stale/malformed patch (the validated `value` flows straight into the
457+ // patch). `findUnsafeMutationValues` validates the mutation upstream.
458+ const xMutation = {
459+ type : "update-property" ,
460+ animationId : existingSet . id ,
461+ property : "x" ,
462+ value : newX ,
463+ } as const ;
464+ const yMutation = {
465+ type : "update-property" ,
466+ animationId : existingSet . id ,
467+ property : "y" ,
468+ value : newY ,
469+ } as const ;
470+ // Patch BOTH coalesced commits. If the SECOND POST fails server-side, the
471+ // first (x) already persisted — patching its commit too means the live
472+ // preview still reflects what DID persist. The x commit carries skipReload
473+ // (no reload), so its instantPatch gives instant feedback without a reload;
474+ // the y commit triggers the soft reload (skipped when the patch applies).
475+ await callbacks . commitMutation ( selection , xMutation , {
476+ label : "Move layer" ,
477+ skipReload : true ,
478+ coalesceKey,
479+ instantPatch : setPatchFromUpdateProperty ( selector , xMutation ) ,
480+ } ) ;
481+ await callbacks . commitMutation ( selection , yMutation , {
482+ label : "Move layer" ,
483+ softReload : true ,
484+ coalesceKey,
485+ // Final commit of the coalesced x/y pair: carry both channels so the
486+ // runtime `tl.set` lands the complete {x,y} pose in place.
487+ instantPatch : setPatchFromUpdateProperties ( selector , [ xMutation , yMutation ] ) ,
488+ } ) ;
441489 return ;
442490 }
443491 await callbacks . commitMutation (
@@ -482,21 +530,21 @@ export async function commitStaticGsapRotation(
482530 callbacks : GsapDragCommitCallbacks ,
483531) : Promise < void > {
484532 if ( existingSet ) {
485- await callbacks . commitMutation (
486- selection ,
487- {
488- type : "update-property" ,
489- animationId : existingSet . id ,
490- property : "rotation" ,
491- value : newRotation ,
492- } ,
493- {
494- label : "Rotate layer" ,
495- softReload : true ,
496- // Value-only rotation set: patch the runtime `tl.set` rotation in place.
497- instantPatch : { selector , change : { kind : " set" , props : { rotation : newRotation } } } ,
498- } ,
499- ) ;
533+ // Derive the instantPatch from the SAME mutation object that's POSTed (single
534+ // source of truth — see commitStaticGsapPosition), so the validated `value`
535+ // flows into the patch and the two can't drift.
536+ const rotationMutation = {
537+ type : "update-property" ,
538+ animationId : existingSet . id ,
539+ property : "rotation" ,
540+ value : newRotation ,
541+ } as const ;
542+ await callbacks . commitMutation ( selection , rotationMutation , {
543+ label : "Rotate layer" ,
544+ softReload : true ,
545+ // Value-only rotation set: patch the runtime `tl. set` rotation in place.
546+ instantPatch : setPatchFromUpdateProperty ( selector , rotationMutation ) ,
547+ } ) ;
500548 return ;
501549 }
502550 await callbacks . commitMutation (
0 commit comments