fix(studio): stop a group drag from jumping, and commit it in one request - #3139
fix(studio): stop a group drag from jumping, and commit it in one request#3139miguel-heygen wants to merge 4 commits into
Conversation
f4825c5 to
655c6a4
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 655c6a4fb.
Right diagnosis for the two things this closes. The snapback: a seek re-renders the WHOLE timeline, not just the tween you patched, so under a sequential per-member commit every queued sibling gets repainted from its un-patched tween (its pre-drag position) until its own patch lands — the one-element flash the PR body describes. The deferSeek fix at gsapRuntimePatch.ts:319-325 is the right seam: patch the tween's vars, defer the seek to the last patch of the batch, and the group repaints once. Contract test at gsapRuntimePatch.test.ts:530-568 locks it directly (first patchRuntimeTweenInPlace(..., deferSeek=true) doesn't call seek; the last one does, and only then does rendered.b === 600 appear).
The one-request commit path is the natural pair: coalescedCommit queues instead of sending, flushQueued() batches via gsapCommitMutation.batch(...), and the intercept's fetch fallback flushes BEFORE reading the file — which is the load-bearing ordering (any queued write has to be on disk before a stale-tween re-read, or the parse resolves against a file missing writes it's about to build on). Comment at useGsapAwareEditing.ts:236-239 names that invariant. instantPatches (plural) wiring in applyPreviewSync (useGsapScriptCommits.ts:252-267) plus the fall-back-if-any-miss shape at :267 is the correct escalation: half-patched preview is worse than reloaded.
Two concerns and a couple of small notes below.
Concerns
Preflight parallelization changes failure-tracking cardinality. useGsapAwareEditing.ts:189-207 — the preflight loop is now await Promise.all(updates.map(async ({ selection }) => { try { … } catch (error) { trackGsapInteractionFailure(...); throw error; } })). Pre-PR it was a sequential for loop that broke on the first throw — one failure tracked per drag. Now every member's rejected promise fires its own trackGsapInteractionFailure before Promise.all rejects with whichever settled first. A group where two members are unwritable will report two telemetry entries where the sequential version reported one. Whether that's the semantic you want depends on how gsap_interaction_failure is interpreted downstream — if it's "one incident per user action," it's now overcounted for multi-failure drags. Cheap fix if you want to preserve one-per-drag: run them concurrently with Promise.allSettled, collect, track only the first (or a single aggregated entry), rethrow it.
fetchParsedAnimations in-flight sharing has a theoretical shared-pre-write race. keyframeCacheAstLoad.ts:53-58 — the sharing docstring says "a call made after a write still gets a fresh parse" because the entry is dropped on settle. That holds for callers that arrive AFTER the previous parse settled. But if a read-only caller (e.g. useEnableKeyframes.ts:233, useGsapAnimationFetchFallback.ts:63) has a parse in flight when flushQueued() completes inside the drag intercept, the intercept's subsequent fetchParsedAnimations(...) will piggyback on that in-flight promise and receive its pre-write snapshot — the exact anti-pattern the sharing was supposed to prevent. The window is narrow (an unrelated read has to be in flight for the same file at the exact moment the intercept's fetch fallback runs), and the covered tests don't exercise it. Not a blocker; naming it so a future contributor adding a concurrent parse source knows to think about it. The strictest fix would be a "no-share within N ms of a self-write" gate on inFlightParses, keyed on a self-write timestamp.
Nits
applyPreviewSync silently drops options.instantPatch when options.instantPatches is also set. useGsapScriptCommits.ts:252 — const patches = options.instantPatches ?? (options.instantPatch ? [options.instantPatch] : []);. If a caller ever sets both (shouldn't today because the batch wrapper at :369-372 gathers them into the plural, and the singular flow only sets the singular — but the type surface allows both), the singular is silently discarded rather than merged/asserted. A one-line assertion or a merge would remove the ambiguity from the type surface.
Post-loop flushQueued() catch reports the last selection as the failing member. useGsapAwareEditing.ts:249-256 — const selection = updates.at(-1)?.selection as the trackGsapInteractionFailure argument. If the batch write fails at the flush, the actual failing member isn't determinable from the batch response, so the last-selection proxy is reasonable, but the tracking will attribute failures to whichever member happens to be last regardless of the real cause. Worth naming in the comment; the alternative (attribute to null, tag phase: "batch-flush") reads clearer.
Docstring on endManualOffsetDragMembers at manualOffsetDrag.ts:527 — "Teardown after a COMMITTED drag." — Small tightening: restoreManualOffsetDragMembers right above at :520 is the paired rollback path (implicit from the file, not explicit in the comment); the "COMMITTED" caps read as an alarm bell without saying what's on the other side. A one-liner on both saying "commit vs restore" would make the pair visible at a glance.
What lands cleanly
deferSeekdesign + test — direct contract lock at the runtime-patch layer.- In-flight parse sharing — the entry-dropped-on-settle rule is right for the normal case, and the three tests (overlap-shares, cross-file-separates, after-settle-fetches-again) pin the intended shape. Docstring at
keyframeCacheAstLoad.ts:45-52names why the parses are shareable in the first place (whole-file read + parse) and what the drop-on-settle contract guarantees. instantPatchesbatched path + fall-back-if-any-miss —applyPreviewSynctest atuseGsapScriptCommits.test.tsx:118-135proves the last-patch-only-seeks shape;:139-159proves the miss-fall-back shape and the telemetry entry.- Written-before-parsed invariant in the intercept fetch fallback —
useGsapAwareEditing.ts:236-241—await flushQueued(); return makeFetchFallback(selection)();is the correct ordering, and the comment names why: "Anything already queued has to be on disk before that read, or it resolves against a file missing writes it is about to build on." - Group-commit renderOnCommit toggle —
useGsapAwareEditing.ts:225—renderOnCommit = index === updates.length - 1— clean, andwithGroupOptionssnapshots the value at push time so mid-loop flushes don't accidentally render.
Series note: base of the preview-fixes slice at #3138 → #3139 → #3140, all sitting on #3137's write-token infrastructure. The one-batch commit path plays cleanly with #3137's per-request write receipts (one token, one receipt for the whole group). LGTM from my side; the concerns above are conditions worth naming rather than blockers.
655c6a4 to
77a38ce
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
R1 review
Verdict: APPROVE with follow-ups (high confidence). Root cause is correctly diagnosed, server-side batch is atomic, tests cover the deferSeek and instantPatches primitives well. Two edge cases worth logging as non-blockers.
Summary
Fixes group-drag member snap-back and collapses N per-member writes into one batch. The mechanism matches the PR body: each pre-PR per-member write did seekToCurrent(iframe, timeline) which re-renders the WHOLE timeline, so members whose runtime tween hadn't yet been patched repainted from their old vars → flashed to the pre-drag position. New deferSeek on patchRuntimeTweenInPlace, a deferPreviewSync option, an instantPatches[] variant, and a queue-flushed batch commit through gsap-mutations-batch together make the "N writes / N seeks" flow into "1 batch write / 1 seek". The inFlightParses map dedups overlapping parses for the same file. Server route at packages/studio-server/src/routes/files.ts:3059-3079 → applyGsapMutations (lines 1227-1301) sequences mutations against a single in-memory script and single-writes only if every mutation succeeds — batch is atomic and consumes one X-Hyperframes-Write-Token, no receipt gap.
P1 blockers
None.
P2 non-blockers
P2-1. Mid-loop flush + patch miss = missed member's runtime is never re-synced.
packages/studio/src/hooks/useGsapScriptCommits.ts:246-291 (applyPreviewSync) — when a batch flushed mid-loop has options.deferPreviewSync === true and patchRuntimeTweenInPlace returns false for some patch, the if (options.deferPreviewSync) return runs AFTER a failed instant-patch attempt, so the soft-reload fallback is skipped. The final-flush batch (deferPreviewSync=false) doesn't re-apply the missed patch — its patches array is [C], not [A, C]. When the final seekToCurrent fires, the runtime timeline still has A's un-patched vars → A snaps back on the final seek — exactly the class of bug the PR is fixing.
Repro: three-member drag where the first member's element has a motionPath on its position tween, OR a += dynamic vars value on x/y (both make patchSet return false). Joint probability with an unrelated member triggering a consolidate-position-writes / resolveGroupTween mid-loop flushQueued is small but non-zero.
Suggested fix: on a mid-loop patch miss, either (a) short-circuit to a soft reload immediately (correctness > "one repaint"), or (b) latch a "force-reload-on-final-flush" flag that the last applyPreviewSync honors even when its own patches succeed.
P2-2. finishUnchangedMutation gate ignores instantPatches.
packages/studio/src/hooks/useGsapScriptCommits.ts:129 — if (!options.skipReload && options.instantPatch) { applyPreviewSync(...) }. The batch path (runBatchCommit, line 373) only sets options.instantPatches, never options.instantPatch. So if a batch resolves with result.changed === false (every mutation was a semantic no-op), the deferred runtime patches never land. Asymmetric with the singular path at line 402-415 of the test file. Change the guard to options.instantPatch || (options.instantPatches?.length ?? 0) > 0.
P3 nits
- No unit test for
useGsapAwareEditing.tsgroup-drag orchestration. The queue /flushQueued/renderOnCommit-closure / mid-loop-flush logic (lines 154-262) is where the tricky lifecycle lives and it's covered only transitively by the primitives' tests. A test that driveshandleGsapAwareGroupPathOffsetCommitwith a mockedgsapCommitMutation.batchand asserts (a) exactly onebatchcall with all N mutations in order, (b) preflight fires NmakeFetchFallbackcalls that dedup to onefetch, (c) a mid-loop consolidate detour still produces coherent write ordering, would pin the invariants the PR body claims. - In-flight parse sharing races with concurrent writes.
keyframeCacheAstLoad.ts:54-68— the JSDoc claims "a call made after a write still gets a fresh parse", which holds when the write is sequenced after the parser has settled. It does NOT hold when the write completes while a parse is in-flight and a new caller joins that in-flight promise: the joiner sees pre-write animations. In this PR's own flow the risk is bounded (the group-drag fetch fallback doesawait flushQueued()before callingfetchParsedAnimations), but any OTHER caller racing against the group-drag flush could get a stale parse. Consider bumping a per-file "generation" counter and dropping the in-flight entry on any write to the same key. - "1 write for 3 drags" is best-case, not always. Mid-loop
flushQueuedtriggered byresolveGroupTween's "legacy mixed" split (gsapRuntimeBridge.ts:91) orconsolidate-position-writes(line 204) will produce 2-3 writes for the group. Users of the metric downstream should know the number is scene-dependent. flushQueuedinherits batch-level options fromcalls.at(-1)?.options. Works for current call site because the last-queued is always the terminal drag with the rightdeferPreviewSyncstate, but a future caller that queues heterogeneous ops could surprise itself. Consider passing batch-level options explicitly.- Partial-write atomicity on error. If mid-loop
flushQueuedsucceeds for members [0..k-1] but member k's intercept throws, [0..k-1] are on disk while [k..N-1] never land. Matches pre-PR semantics; worth a JSDoc line if you care to be explicit.
Per-lens findings
- What triggers snapback — confirmed.
gsapRuntimePatch.ts:319-324+ new test atgsapRuntimePatch.test.ts:37-70document/exercise the "seek re-renders WHOLE timeline; queued-but-unpatched members repaint from old vars" mechanism. - One-request commit atomicity — server side atomic (
files.ts:1227-1301: singlewriteMutationResultfires only after every mutation succeeds; conflict-check at line 1273 rejects with 409 on concurrent external write). Client side atomic per-batch. Cross-batch is best-effort — see P3. - Mid-drag lifecycle — no new
useEffect, no state syncing.manualOffsetDrag.tschanges are JSDoc-only. Drag teardown continues to fire once per user action viarunGestureTransaction. - Write-token receipt (#3137) —
mutateGsapScriptBatchsendsstudioWriteHeaders()→ one token → onewriteFileWithReceipton the server → one receipt. Clean. - Snapback UI state / re-drag mid-commit — commit fires after
pointerup; no gesture is active during the async batch.data-hf-drag-paused-timelinesis per-element, safe. - Element identity across snapback — no ID reassignment.
preflightAnimations = new Map<DomEditSelection, ...>keys by object identity. useEffect/ state syncing — none added.- Server-side batch atomicity — response shape
{ok, changed, mutated, parsed, before, after, scriptText, path, version, backupPath}returns a single before/after for the whole batch; clientfinalizeSuccessfulMutationrecords ONEeditHistory.recordEditper batch so undo is atomic. - Test coverage — primitives (
deferSeek,instantPatches,inFlightParses) well covered with assert-on-persisted style.applyPreviewSyncbatch miss-fallback test is there. Gap: no test for orchestration inuseGsapAwareEditing.ts(P3). - CI — at HEAD
655c6a4f: all completed checks SUCCESS. regression-shards 2/3/5/7/8 + Windows still IN_PROGRESS at review time; no failures.
— Via
be8525f to
7a389d9
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
R2 re-verify @ 7a389d979
Verdict: APPROVE. Both R1 P2 findings fixed via a shared previewFallbackLatch pattern; the fix commit 7a389d97 "fix(studio): carry deferred preview fallbacks" also closes the P3 in-flight parse race. Adversarial state-machine walk on the new latch code came back clean.
Per-finding delta:
- P2-1 (mid-loop miss swallowed by
deferPreviewSync): FIXED via approach (b) — the latching flag. New typepreviewFallbackLatch: { pending: boolean }inpackages/studio/src/hooks/gsapScriptCommitTypes.ts:36. Instantiated per group-drag inuseGsapAwareEditing.ts:182and threaded onto every queued call viawithGroupOptions(:188) — same object identity across all members. InuseGsapScriptCommits.ts::applyPreviewSync(:249-303): seedsneedsFallbackfromoptions.previewFallbackLatch?.pending(sticky at:256); on any miss setsneedsFallback = trueand firesgsap_instant_patch_fallback(:269-274); early-returns only when!needsFallback(:277); mid-loop latchespending = needsFallbackand returns (:280-283); final flush clears the latch and executessoftReloadOrEscalate(:285-302). Legacy mid-loop path without a latch is unchanged at:284(fallback fires immediately — preserved for standalone deferred writes). - P2-2 (
finishUnchangedMutationignoresinstantPatches): FIXED in the same commit. Guard atuseGsapScriptCommits.ts:129-132now reads(instantPatchesFor(options).length > 0 || options.previewFallbackLatch?.pending). HelperinstantPatchesFor(:305-312) merges pluralinstantPatchesand singularinstantPatch. Also honors a still-pending latch on a no-op batch — needed for the "final flush is a semantic no-op but a mid-loop member missed" case.
Adversarial state-machine pass — walked every combination of {mid-loop | final-flush} × {patches-ok | miss} × {latch-true | latch-false | no-latch}: all transitions correct. Object identity of previewFallbackLatch survives {...options} spread, runBatchCommit's { instantPatch: _, ...batchOptions } destructure (latch is on rest), and flushQueued's {...calls.at(-1)?.options, label: ...}.
New tests directly cover both fixes:
useGsapScriptCommits.test.tsx:172-208— "carries a deferred patch miss into the final batch render" (P2-1 latch across two applyPreviewSync calls; assertsapplySoftReloadruns exactly once on the final call and latch is cleared).useGsapScriptCommits.test.tsx:210-227— "falls back immediately when a deferred patch miss has no final-render latch" (guards the non-latched deferred path from regressing).useGsapScriptCommits.test.tsx:499-532— "no-op batch still applies every plural instant patch" (P2-2 plural path).keyframeCacheAstLoad.test.ts:79-107— "supersedes an in-flight pre-write parse with a fresh post-write read" (closes the R1 P3 parse race;keyframeCacheAstLoad.ts:59,62acceptsfresh: truewhich deletes the in-flight key before the new fetch).useGsapAwareEditing.test.tsxadds 4 group-drag tests covering sharedcoalesceKey, preflight-all-before-first-write, preflight fail-closed, and first-error attribution.
P3 status:
- Parse race — FIXED + test.
- No unit test for
useGsapAwareEditingorchestration — PARTIALLY addressed (4 tests added; latch-carry gap covered at the layer below). - "1 write for 3 drags" is best-case — unchanged; accurate caveat, not a defect.
flushQueuedinherits batch options fromcalls.at(-1)?.options— unchanged; safe because all queued calls receive identicalwithGroupOptionsmodulorenderOnCommit-captureddeferPreviewSync.- Partial-write atomicity on error — unchanged.
No new defects found in the incremental diff.
CI: 0 failed required checks. Green on completed jobs; Typecheck, Build, Analyze (js-ts), Producer integration, all 9 regression-shards, Perf lanes, Preview parity, Render+Tests on windows-latest, CLI smoke, CLI:npx (windows) still IN_PROGRESS.
Stamp holds pending in-progress checks completing green.
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Delta-reviewed 655c6a4fb..7a389d979 against prior R1 findings.
All R1 items addressed cleanly, and the two claims in Miga's note ("plural instant patches run for unchanged batches; deferred patch misses reach the final render") land where she said.
Prior R1 findings — verdict
Concern 1 — Preflight parallelization changing telemetry cardinality: FIXED. useGsapAwareEditing.ts:220-245 — parallel preflight is now await Promise.allSettled(updates.map(async ({ selection }) => { ... })), and firstPreflightFailure at :44-54 iterates results in input-order and returns the FIRST rejected result's error+selection. Single trackGsapInteractionFailure call per drag, with deterministic first-failing-member attribution. Test at useGsapAwareEditing.test.tsx:304-338 locks it: expect(trackGsapInteractionFailure).toHaveBeenCalledOnce() with updates[0]?.selection even when multiple members reject.
Concern 2 — fetchParsedAnimations shared pre-write parse race: FIXED. keyframeCacheAstLoad.ts:53-70 — the in-flight sharing map still exists (as designed), but the intercept now opts into a fresh read via options.fresh. At :62, if (options.fresh) inFlightParses.delete(key) evicts any pre-write in-flight parse BEFORE the post-write fetch starts, so the intercept's read after flushQueued() builds its own promise against the fresh disk contents. The .finally guard at :66-68 (if (inFlightParses.get(key) === request) inFlightParses.delete(key)) is the load-bearing half — a superseded pre-write request can't evict the fresh post-write entry when the pre-write settles late. The intercept's call site at useGsapAwareEditing.ts:258-261 (async () => { await flushQueued(); return makeFetchFallback(selection, { fresh: true })(); }) is the only production caller passing fresh: true, which is right: read-only callers that overlap the write don't need the eviction. Test at keyframeCacheAstLoad.test.ts:79-107 ("supersedes an in-flight pre-write parse with a fresh post-write read") locks the shape directly. useGsapAnimationFetchFallback.ts:66-67 also correctly propagates fresh only to the first attempt (retries drop it — a retry after a settled fresh call would defeat the point).
Nit 1 — applyPreviewSync silently drops options.instantPatch when options.instantPatches also set: FIXED. useGsapScriptCommits.ts:305-312 now merges via instantPatchesFor(options): [...(options.instantPatches ?? []), ...(options.instantPatch ? [options.instantPatch] : [])]. Not type-narrowed (both remain optional on the interface), but runBatchCommit at :394 explicitly strips the singular before injecting the plural, so no real path sets both — and if a future caller does, both run rather than one silently dropping.
Nit 2 — Post-loop flushQueued() catch attributes to last member: FIXED. useGsapAwareEditing.ts:270-277 now passes null selection to trackGsapInteractionFailure with a comment: // The aggregate write has no uniquely failing member; do not misattribute // its telemetry to whichever member happened to be last in the array.. useGsapInteractionFailureTelemetry.ts:10 accepts selection: DomEditSelection | null and falls back to activeCompPath ?? "index.html" for the null case. Aggregate attribution — clean.
Miga's claims — verified
"Plural instant patches run for unchanged batches." useGsapScriptCommits.ts:344-350 (finalizeSuccessfulMutation, changed === false branch) now calls finishUnchangedMutation, which at :122-136 guards if (result.changed !== false) return false; then applies if (!options.skipReload && (instantPatchesFor(options).length > 0 || options.previewFallbackLatch?.pending)) { applyPreviewSync(iframe, result, options, reloadPreview); }. runBatchCommit at :391-395 collects each call's instantPatch into the batch's instantPatches, so an unchanged batch still runs every one. Test at useGsapScriptCommits.test.tsx:499-531 ("no-op batch still applies every plural instant patch") asserts both #a and #b patch runs and no reload — direct.
"Deferred patch misses reach the final render." useGsapScriptCommits.ts:249-303 (applyPreviewSync): line 256 pre-seeds needsFallback = options.previewFallbackLatch?.pending === true from any prior deferred miss. When any patch misses, sets needsFallback = true and emits gsap_instant_patch_fallback telemetry. Deferred callers at :283-289 (if (options.deferPreviewSync && options.previewFallbackLatch) { options.previewFallbackLatch.pending = needsFallback; return; }) stash the miss into the shared latch (useGsapAwareEditing.ts:182 const previewFallbackLatch = { pending: false };, injected via withGroupOptions:188). The final commit runs with deferPreviewSync=false (:247 renderOnCommit = index === updates.length - 1) and falls through to softReloadOrEscalate even when its own patches all succeed — because the pre-seeded needsFallback=true from an earlier deferred miss forces the escalation. Test at useGsapScriptCommits.test.tsx:172-207 verifies: first defer with miss (patchRuntimeTweenInPlace.mockReturnValueOnce(false)) → pending=true, no reload; final call → applySoftReload called once. Standalone deferred miss without a latch falls back immediately at :209-226. Sound.
New scan — nothing worth blocking
gsapRuntimePatch.ts:284-330—deferSeekdefaultfalse, well-docblocked;applyPreviewSync:259-267setsdeferSeek || index < patches.length - 1so only the last patch in an eager batch seeks. Correct pairing withdeferPreviewSync.gsapScriptCommitTypes.ts— three new fields (deferPreviewSync,previewFallbackLatch,instantPatches), documented.previewFallbackLatchis the one shared-mutable — mitigated because a single group drag owns and passes one latch object to every call in its queue, and the object dies with the drag.useGsapAwareEditing.ts— main new complexity ispreflightAnimationscaching (:212, 222, 252), thepreviewFallbackLatchplumbing, and the interimflushQueuedinside the fetch fallback (:258-261). No new race hazards spotted: interim flush usesqueued.splice(0, queued.length), and the latch survives across interim + final flushes.useGsapInteractionFailureTelemetry.ts— newnull-selection branch is the pair to the aggregate-flush attribution fix; verified the fallback compositePath resolution is sensible.
Clean pass. LGTM from my side; the residual "read-only callers overlapping a write get the pre-write parse" is by design (they're not the drag intercept and don't need the eviction) and is worth naming here so a future contributor doesn't confuse it for a bug.
7a389d9 to
81e9420
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Re-verified 81e942090 against prior R2 (7a389d979).
Every file in this PR's own scope is byte-identical to the prior R2 head — the delta at this SHA is stack drift from the fileWatcher fix in #3137. Prior R2 verdict stands: both R1 concerns (preflight parallel telemetry cardinality; fetchParsedAnimations shared pre-write parse) FIXED via Promise.allSettled + firstPreflightFailure and the fresh: true option with orphan-safe .finally. Both Miga's claims (plural instant patches on unchanged batches; deferred misses reach the final render) verified with contract-lock tests. Review at 4892125155.
No new findings.
vanceingalls
left a comment
There was a problem hiding this comment.
R3 re-stamp @ 81e9420 — Byte-clean rebase on #3137 R3 (f1de808). Own-delta against parent (f1de808e7...81e942090) = 46 files with blob SHAs and +/- counts identical to R2 own-delta (4d2a48ae6...7a389d979); no code change to #3139 introduced by the rebase, only the fileWatcher burst-fix propagated up from #3137. Prior R2 approval stands. — Via
Dragging several elements at once and dropping them made one of them snap back to where it started for a frame or two, then jump forward again. Each member of the group is written separately, and every write patched the live GSAP tween in place and then seeked the player. A seek re-renders the WHOLE timeline, not the tween that changed, so the members still queued behind that write got repainted from their un-patched tweens: back to their pre-drag position, where they sat until their own write landed. Only members whose tween actually renders at the playhead showed it, which is why a group of three flashed one element and left the others still. The group commit now defers the seek for every member but the last, so the queued members keep the transform the gesture left on them and the whole group repaints once, from the fully patched timeline.
Dragging N elements cost N writes and 9 reads for a three-element group: each member fetched the composition's parse to preflight, fetched it again to resolve its tween, then wrote the file on its own round trip. Every one of those writes re-read, re-parsed and re-serialized the whole composition. Three changes, same behaviour: - The parse endpoint shares an in-flight request per file, so callers asking for the same composition at the same moment get one request. Only overlapping calls share — the entry is dropped as soon as it settles, so a read after a write still gets a fresh parse. - The group preflight runs its members together instead of one at a time. A preflight writes nothing, so there is nothing to order. - Members' mutations are queued and sent as one batch write. Anything that re-reads the file flushes the queue first, so a member resolving a shared or stale tween never reads a composition missing writes it is about to build on. The batch carries each member's runtime patch, and only the last one re-renders. A three-element group drag now issues 2 reads and 1 write, down from 9 and 3.
81e9420 to
dff382a
Compare
|
Landed in #3146 — same commits, on an unstacked branch. GitHub refuses base retargeting and admin merge on a stacked PR, and the async merge endpoint it does allow does not apply the ruleset bypass, so this could not be merged in place. All content is on main. |
What
Dragging a group no longer makes one member snap back to its starting position for a frame, and the whole group is saved in one request instead of one per member.
Why
Each member of a group was written separately, and every write patched the live GSAP tween in place and then seeked the player. A seek re-renders the whole timeline, not the tween that changed, so members still queued behind that write got repainted from their un-patched tweens — back to their pre-drag position, where they sat until their own write landed. Only members whose tween renders at the playhead showed it, which is why a group of three flashed one element and left the others still.
Separately, a three-element drag cost 3 writes and 9 parse reads, each write re-reading and re-serializing the whole composition.
How
The group commit defers the seek for every member but the last, so queued members keep the transform the gesture left on them and the group repaints once from the fully patched timeline. Mutations are queued and sent as one batch write; anything that re-reads the file flushes the queue first, so a member resolving a shared or stale tween never reads a composition missing writes it is about to build on. The parse endpoint shares an in-flight request per file.
Test plan
Part of a stack re-cutting #3077, which stays open as the reference for the whole tree. Preview fixes land first, then the rich-text feature.