Skip to content

feat(studio): instantPatch fast path in runCommit#1613

Open
miguel-heygen wants to merge 3 commits into
feat/instant-patch-helperfrom
feat/instant-patch-wiring
Open

feat(studio): instantPatch fast path in runCommit#1613
miguel-heygen wants to merge 3 commits into
feat/instant-patch-helperfrom
feat/instant-patch-wiring

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR wires the value-only instantPatch fast path into the GSAP commit pipeline. When a drag only changes the values of an existing tween (a static set of position or rotation), runCommit now 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 patchRuntimeTweenInPlace helper landed in #1612; this PR connects it to commitMutation/runCommit and routes the static position/rotation drags through it. Structural and keyframe edits deliberately do not opt in.

What's changed

gsapScriptCommitTypes.ts

Adds optional instantPatch?: { selector: string; change: RuntimeTweenChange } to CommitMutationOptions. The doc comment spells out the contract: when present, runCommit first 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.ts

  • Extracts the preview-sync decision out of runCommit into a new pure, exported helper applyPreviewSync(iframe, result, options, reloadPreview) (no React → directly unit-testable).
  • Logic: if options.instantPatch is set, call patchRuntimeTweenInPlace(iframe, selector, change); on true return early (element already correct, no reload); on false / no instantPatch, fall through to the existing path (soft-reload when softReload && result.scriptText, escalating to reloadPreview() if the soft reload fails, else full reloadPreview()).
  • runCommit delegates to applyPreviewSync(...) instead of inlining the branch; the prior applied bookkeeping is removed; onCacheInvalidate() still fires in every case. Imports patchRuntimeTweenInPlace.

gsapDragCommit.ts

  • Imports RuntimeTweenChange and adds instantPatch? to the commitMutation callback options in GsapDragCommitCallbacks.
  • commitStaticGsapPosition — the final commit of the coalesced x/y pair (the update-property for y, carrying softReload: true) also attaches instantPatch: { selector, change: { kind: "set", props: { x, y } } } (the full {x, y} so the runtime tl.set is patched in one shot). The intermediate x commit stays skipReload, no patch.
  • commitStaticGsapRotation — the value-only rotation update-property commit attaches instantPatch: { selector, change: { kind: "set", props: { rotation } } }.
  • The structural branches (new set tween, keyframe conversion) are untouched and carry no instantPatch.

Why

patchRuntimeTweenInPlace already 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 existing set's values), that reload is wasted work and adds visible latency. instantPatch lets a commit declare "this is a value-only change to this tween — try patching it directly"; runCommit honors that, giving instant feedback while keeping the source write + cache invalidation intact. The patch → soft reload → full reload fallback means anything structural or ambiguous degrades gracefully to exactly today's behavior.

Testing

  • useGsapScriptCommits.test.tsx (new) — mocks patchRuntimeTweenInPlace + applySoftReload. applyPreviewSync (pure): six cases covering the full matrix (instant succeeds → no reload; instant fails → soft; instant+soft fail → full; and the three no-instantPatch cases 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.tscommitStaticGsapPosition (intermediate x is skipReload no patch; final y carries softReload + full {x,y} patch; adding a new set is structural, no patch); commitStaticGsapRotation (update carries {rotation} patch; add is structural); commitGsapPositionFromDrag keyframe drag is structural (no instantPatch).
  • gsapRuntimeBridge.test.tstryGsapDragIntercept forwards instantPatch { 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.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — findUnsafeMutationValues doesn't guard the fast path. runCommit calls findUnsafeMutationValues(mutation) before the POST and bails with a user-facing toast on unsafe values. The instantPatch fast path runs patchRuntimeTweenInPlace(iframe, selector, change) against options.instantPatch.change — not mutation. patchSet/patchKeyframes re-check Number.isFinite for their own inputs (so a raw NaN can't slip through), but if the mutation carries unsafe values, the early unsafeFields.length > 0 path throws/returns BEFORE the patch is attempted — so we're fine on that side. The asymmetry to watch is the other direction: instantPatch.change.props is 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 same newX/newY locals, so it's tight. Worth a code comment in commitStaticGsapPosition noting the two must stay in lockstep (or, more durable: derive instantPatch.change.props from the just-built mutation object 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 attaches instantPatch: { kind:"set", props:{x,y} }; if the live runtime has a separate tl.set("#el",{rotation}) shape, the patch can land on the rotation tween and force-write x/y channels there. Source file is correct, runtime is inconsistent until next full reload. Either tighten resolveRuntimeTween (prefer tween whose vars already 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. commitStaticGsapPosition does {x: skipReload} then {y: softReload + instantPatch}. The intermediate POST writes the new x server-side but leaves the live tween untouched (no patch, no reload). If the second POST fails server-side, the intermediate update-property x is now persisted but the user's visual feedback never reflected it — and the instantPatch block in runCommit is downstream of the throw, so no patch fires either. The final-coalesce contract assumes the second commit always lands; worth a comment, or attaching instantPatch to both commits with skipReload on 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)
  • applyPreviewSync doc comment says "On false — or when no instantPatch is supplied — fall back to the existing soft/full reload." Once #1605 lands and drops the → full reload escalation, this sentence is stale. (nit, fixed by #1605)

What I didn't verify

  • Whether __player.seek(currentTime) after an in-place vars mutation 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 the onSeek callback model.
  • The serializerRef ordering across the coalesced x/y pair vs other simultaneous edits to the same file — the existing gsap-file:${file} per-file serializer should chain them, but I didn't trace the interleaving for a same-file commitStaticGsapPosition colliding with a commitStaticGsapRotation.

— Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 set tween (or vice versa). Source-side writer discriminates (findPositionSetAnimation / findRotationSetAnimation); patchSet doesn't. Position drag with {x,y} lands on a sibling rotation set tween → force-writes x/y channels into vars that 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 at resolveRuntimeTween.

  • Coalesced x/y intermediate-commit silently swallows preview state on the first commit's failure. commitStaticGsapPosition does {x: skipReload} then {y: softReload + instantPatch}. If the SECOND commit fails server-side, the persisted update-property x is stale on screen — the instantPatch block in runCommit is downstream of the throw, no patch fires either. The final-coalesce contract assumes the second commit always lands. Either disclose explicitly OR attach instantPatch to BOTH commits with skipReload: true on the first (the patch is idempotent, so re-applying is safe). Band-aid pattern #3 (silent scope gap on coalescence contract).

  • findUnsafeMutationValues lockstep gap. findUnsafeMutationValues(mutation) runs before POST; the instantPatch path runs patchRuntimeTweenInPlace(iframe, selector, change) against options.instantPatch.change — independently sourced. Today both come from the same newX/newY locals so it's tight. Worth either (a) deriving instantPatch.change.props from the just-built mutation object so they can't drift, or (b) a code comment in commitStaticGsapPosition noting 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:

  • applyPreviewSync doc comment "fall back to existing soft/full reload" is stale once #1605 drops the → full reload escalation. 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.)
@miguel-heygen miguel-heygen force-pushed the feat/instant-patch-helper branch from dc79a2a to 2a0df60 Compare June 20, 2026 21:20
@miguel-heygen miguel-heygen force-pushed the feat/instant-patch-wiring branch from decff5d to ff2be9d Compare June 20, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants