Repository navigation
fix(studio): the server picks an added effect's element id; one client for patch and gsap writes - #4962
Conversation
72f4d35 to
69e32d9
Compare
2017b86 to
3ef05a7
Compare
Edit accuracy: accurate 1556 (base branch 1556), smooth 1417 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (1)
|
fc7902f to
d6734ab
Compare
…ject leaves no id
d6734ab to
497ed03
Compare
There was a problem hiding this comment.
Reviewed at 497ed032.
This is a comment, not an approval. I found no blockers. The refactor preserves behaviour on the three routes it touches, and the rollback closes the refused, network and no-project cases it names. I have one real gap in the rollback and two smaller notes.
Findings, most severe first
1. The rollback can strip an id that a second, saved add wrote (low-medium, nonblocking). useGsapAnimationOps.ts:143 removes the id with no condition: if (!assigned) selection.element.removeAttribute("id");. Here is how that goes wrong. Two adds run on the same element from the same selection, which the PR body already lists as reachable. Add 1 mints div on the element. Add 2 still sees selection.id as undefined, so it mints div-2 over it (gsapScriptCommitHelpers.ts:37). If add 1's write is then refused while add 2's is saved, add 1's finally removes div-2. The file holds div-2 and a tween for #div-2, but the preview element has no id. The next "Add effect" on that element then mints and writes a new id over div-2, which leaves that tween targeting nothing. That's the same stray-id problem, coming from the other direction.
I reproduced it with a scratch test in useGsapAnimationOps.test.tsx's harness: add 1's fetch is held and then answers 409, add 2's answers {changed: true}. The request bodies were ["div", "div-2"] and element.id was div-2 before add 1 settled. After it settled, I got expected null to be 'div-2'.
The fix is one line: if (!assigned && selection.element.getAttribute("id") === autoId) selection.element.removeAttribute("id");. With it the scratch test passes, and so do the 17 tests in the two changed files. It also means a rollback can never remove an id this call didn't put there. A separate Codex pass found this race on its own and rated it blocking. I rate it lower because it only happens inside the same-element double add, which the PR body already says is broken (it orphans the first tween even without a refusal). Still, it's a one-line fix, so I'd take it in this PR along with a same-element test. The existing overlap test uses two different elements.
2. "One client per write route" is broader than what this PR delivers (low). The title and the first line of "What" say every Studio write route now has one client. The diff consolidates two: patch-element and gsap-mutations/-batch. file-mutations/remove-elements still has two inline clients, at useTimelineDeleteOps.ts:134 and useElementLifecycleOps.ts:160. They already handle a refusal differently: one throws a plain Error, the other throws createStudioSaveHttpError. That's the same drift your "Why" describes. useFileManager.ts:253/277 also post to /files/ twice. I'd either narrow the title to the two routes or note remove-elements as a follow-up.
3. Two guards on the shared GSAP client aren't pinned by any test (low). Removing ...studioWriteHeaders() from requestGsapMutation (gsapMutationClient.ts:101) still passes 1397/1397 tests across all of src/hooks. So does removing the !res.ok branch from the merged mutateGsapScript (useGsapScriptCommits.ts:56). Both used to exist in three copies, so this gap isn't new, but the write token now lives in exactly one place. Losing it isn't cosmetic. studioWriteHeaders (utils/studioFileVersion.ts:74) is what stops the server echo of Studio's own write from looking like an external change. Without it, every GSAP edit would trigger a full preview reload, which shows up as a flash. One fetch-spy assertion on the header would catch the first. A refused batch or single-route response asserting GsapMutationHttpError (or the toast) would catch the second.
4. Nit: route and body aren't tied together in the type. requestGsapMutation(projectId, route, filePath, body: unknown) (gsapMutationClient.ts:91) lets a caller pair gsap-mutations-batch with a single mutation, or the other way round. The old batch helper built the { mutations } envelope itself. Today's one batch caller is pinned by a test (M8 below), but an overload or a discriminated union would make the wrong pairing a compile error. Codex raised this; I confirmed the signature.
5. Nit. The id write's URL is now built with buildProjectApiPath (useDomEditCommitsHelpers.ts:185), which throws Invalid project ID synchronously. On an invalid id the old code sent the request and showed a toast; now the call rejects without a toast, and the finally still removes the id. In practice pid comes from the same ref useDomEditPersist already passes to postPatchElement, so I don't think this is reachable. I'm noting it only because it's a change in behaviour.
Mutation table
Runner: vitest with NODE_ENV=test. Each mutant ran first against the six related hook files (158 tests). The survivors were re-run against all of src/hooks (145 files, 1397 tests, control run green).
| # | Mutation | Result |
|---|---|---|
| M1 | Remove the rollback (removeAttribute in finally) |
caught: refused, network and no-project tests |
| M2 | Move if (!pid) return above the try |
caught: no-project test |
| M3 | Send the id write through an inline studioApiFetch (same URL, body, token) |
caught: both toast-copy tests |
| M4 | Rethrow the already-toasted refusal instead of returning false |
caught: 3 tests |
| M5 | Drop || result.matched === true |
caught: "file already holds the id" |
| M6 | Disable the unsafe-value guard | caught: non-finite target test |
| M7 | Send the batch to gsap-mutations |
caught: useGsapScriptCommits batch test |
| M8 | Send the batch body as a bare array instead of { mutations } |
caught: same test |
| M9 | Drop !res.ok throw in mutateGsapScript |
survived (1397/1397) |
| M10 | Drop the write token from requestGsapMutation |
survived (1397/1397) |
| M11 | postGsapMutation through a byte-identical inline client |
survived, as expected: it's equivalent, so "one client" can't be checked by a behavioural test |
| M12 | Rethrow raw transport errors from postGsapMutation |
caught: timelineTimingSync transport test |
| probe | Same-element overlap, first refused, second saved | fails at head (finding 1); passes with the guarded rollback |
Things I checked that hold up
- The request is identical before and after for
gsap-mutations,gsap-mutations-batchandpatch-element: same method, URL encoding,Content-Type, write token, and JSON body (batch is still{ mutations }). Each caller keeps its own response handling. A synchronous throw inrequestGsapMutationstill lands insidepostGsapMutation'stry. - The
matchedrule is sound against the current server.patch-element(studio-server/src/routes/files.ts:3036) returnsmatched: true, changed: falseonly when the patched HTML equals the original.idis inALLOWED_HTML_ATTRSand isn't URI-bearing, sohtml-attributenever silently skips it. Today,matched && !changedtherefore means the file already holds that id. That depends on the server's skip behaviour, so ifidever leaves the allowlist, this rule needs another look. - Correction (2026-10-04), after tai's 5403728750: I originally wrote here that a dropped response after the server saved the id recovers on its own. That's only true for the same element. The mint checks uniqueness against the live preview only (
while (doc.getElementById(id)),gsapScriptCommitHelpers.ts:33). Once the rollback strips the saved id from the preview, the next "Add effect" on a sibling can mint the same id. The source then holds two elements with that id, and the write-token echo suppresses the reload that would show it. So the network-lost-response path needs reconciliation, such as a re-read of the file or server-side uniqueness, not just the guarded rollback from #1. I confirmed the mint code at source; tai traced the sequence, and I haven't run it. - There is one id-mint site (
ensureElementAddressable) and one caller of it (addGsapAnimation). A saved id stays on the element even if the tween commit afterwards fails, which is right because the file holds it. The id write records no history entry, before or after. Serialisation throughserializeStudioFileMutationand the SDK skip onautoIdare unchanged. - The toast copy change is the one you documented: "Couldn't save animation: …" becomes "Couldn't save edit: …".
- CI: every check at
497ed0329has completed, with each one passing or path-skipped (Windows, skills, catalog, perf shards). None are red or still running. The edit-accuracy gate shows accurate 1556 vs base 1556, with 0 regressed, unstable or newly passing. The base it read isb71aa9338, which is the latest commit to touchbaseline.json(main is one commit ahead and doesn't change it), so the rebase claim holds. Because the ratchet only flags pass→fail flips, I also compared the measured per-caseresults.jsonagainst basebaseline.json. No case/metric pair (tracking, drop, reload, render) moved more than 0.05 px across the 1562 cases, and the 6 already-failingcrop-scalerender cases match exactly (0.99 ×4, 0.78 ×2). Keep in mind the bench measures drag gestures, not "Add effect", so it doesn't test the id path either way.
What I ran
bun install --frozen-lockfile, then built parsers, lint, studio-server and core.- The two changed test files: 17/17. The 13 hook files around them: 246/246. All of
src/hooks: 1397/1397. - The 12 mutants and the overlap probe above, each reverted afterwards.
gh pr checksand per-job check-runs at the head SHA, and the edit-accuracy gate artifact.- A static Codex pass (
codex exec, read-only, raw diff and PR body only, none of my findings) ran and completed in about 7 minutes. It returned three findings. I checked each one at source: the rollback race (it matches my #1, found separately), the untested write token (matches my #3), and the route/body typing (#4).
— Somu
terencecho
left a comment
There was a problem hiding this comment.
The consolidation of patch-element and GSAP mutation requests preserves their write headers and payload shapes. I independently checked Somu's COMMENT-only review and found a second consequence of the new rollback that makes this a request for changes, not a safe merge-gate stamp.
Blocker — a lost response after a successful id write can persist duplicate HTML ids (packages/studio/src/hooks/useGsapAnimationOps.ts:123–143, packages/studio/src/hooks/gsapScriptCommitHelpers.ts:30–38). Add an effect to id-less element A when its class matches a sibling: the client mints div, the server saves it, but the browser's request rejects before receiving the response. assigned remains false and the new finally removes A's live id, even though its source file has id="div". The self-write token suppresses the file-change reload (studioFileVersion.ts:65–77, useExternalFileChangeCoordinator.ts:424–433). A subsequent add on sibling B therefore sees no live div, mints div again, and the server's HTML-attribute mutation (sourceMutation.ts:279–284) accepts it without an id-uniqueness check. The saved file now has two id="div" elements and #div is no longer an unambiguous animation target. On the base version, A retained its live id after the lost response, so B would mint div-2. A failure before a server write needs rollback; a failure after the write has unknown outcome and must reconcile the persisted source/reload before freeing the id, or the server must reject duplicate ids. Please test a post-commit response failure followed by adding to the sibling.
Somu also reproduced the separate same-element overlapping-add case in <https://github.com/heygen-com/hyperframes/pull/4962#pullrequestreview-5403582444|their review>: the first failed add's unconditional removeAttribute can erase the second add's newly minted div-2, which the queued second write subsequently saves. The file writer queue serializes network writes, not the synchronous DOM id mint; the GSAP soft reload does not restore the missing preview id. A conditional removal only when the element still holds this call's autoId, plus a same-element failure/success regression test, addresses that case, but does not address the cross-element lost-response collision above. Their title-scope and test-pinning notes remain in their review; I am not repeating them as blockers.
At the reviewed head, active required CI checks passed or were path-skipped; edit-accuracy coverage does not exercise this Add-effect sequence. I did not run tests locally or claim a live server/network reproduction.
Verdict: REQUEST CHANGES
Reasoning: The new unconditional rollback can leave the live DOM inconsistent with saved source, and an ambiguous successful write can turn the next ordinary edit into duplicate persisted IDs. Both paths need ownership-aware or server-reconciled cleanup before a stamp clears the merge gate.
— Review by tai (pr-review)
jrusso1020
left a comment
There was a problem hiding this comment.
Requesting changes. This builds on the two reviews already here. The overlap race they found isn't limited to the preview: a later add then saves a wrong change to the user's file.
From stripped id to a broken file. Reproduced with the hook harness for the client side and the real studio-server routes for the server side.
- Adds A and B overlap on one id-less element.
ensureElementAddressablereads the staleselection.idsnapshot (gsapScriptCommitHelpers.ts:22), so A mintsdivand B mintsdiv-2over it on the live element. - A's id write is refused, while B's is saved and commits
tl.from("#div-2"). A'sfinally(useGsapAnimationOps.ts:142-144) then stripsdiv-2from the live element. - The preview never gets the id back.
commitMutation(..., { softReload: true })→applySoftReloadonly kills and re-runs the timeline script on the existing DOM and restores inline styles; it doesn't re-sync ids. Only thecannot-soft-reloadcases do a full reload. So B's tween plays nothing, and the natural next move is another "Add effect". - That third add, with a fresh selection (
id: el.id || undefined,domEditingElement.ts:140), mintsdiv, the first free id in the live document. It posts the id write byhfId. On the server,findTargetElementfinds the element byhfId, andsetAttribute("id", "div")overwritesdiv-2, returningchanged: true.
The saved file then reads:
<div id="div" class="card" data-hf-id="hf-1">A</div>
tl.from("#div-2", { opacity: 0, duration: 2, ease: "power2.out" }, 0); // targets nothing
tl.from("#div", { opacity: 0, duration: 2, ease: "power2.out" }, 0);
B's saved animation is orphaned. It also disappears from the element's animation list, which matches selectors against the live document.
On main, the same sequence leaves div-2 on the element. The third add sends no id write and commits #div-2, so the file stays consistent. I ran the same test against main's useGsapAnimationOps.ts and gsapScriptCommitHelpers.ts to confirm.
Fix for this path: if (!assigned && selection.element.id === autoId) selection.element.removeAttribute("id");. With it, all 17 tests in the two changed files pass, including the no-project and network cases, and the sequence above keeps div-2 without sending a third id write. This doesn't cover the case where the save went through but its response was lost, raised in the other review; that one still needs reconciliation. Please add the overlap sequence as a test.
Smaller points:
matcheddoesn't mean the target element is the one holding this id.patchElementInHtmlresolves byhfId, then byid, then byselector[selectorIndex]. With anhfIdmissing from the file, the selector fallback matched.card[0]and returnedchanged: false, matched: true. The new=== true || matchedrule (useDomEditCommitsHelpers.ts:235) would report the id as saved on whatever element the fallback hit. The same fallback already letschanged: trueland on the wrong element on main, so this isn't new. It's worth knowing before relying onmatched.- Untested header. Dropping
Content-Type: application/jsonfromrequestGsapMutationleaves the tests around it green (86/86). It's probably harmless, since the server doesn't check the header.
What checked out:
- The shared GSAP request sends the same URL, headers and body as the three builders it replaces, and the batch body is covered.
- The toast text changes as described.
- Changed files 17/17, surrounding hook files 86/86, all of
src/hooks1396/1397 (the one failure is a puppeteer launch in a browser test this PR doesn't touch). tscis clean.- These deliberate breaks each turned a test red: swallowing every error in
assignGsapTargetAutoIdIfNeeded, the wrong route inpostGsapMutation, and dropping the refusal toast.
— Rames
…ver repeat The browser minted the id from the live preview and rolled it back when the write failed. A reply lost after the id was saved left the file holding an id the preview had dropped, so the next add on a sibling wrote the same id again; and a refused add could strip the id an overlapping saved add had just given the same element. A new patch-element op, ensure-id, keeps the element's id or makes the proposed one unique in the file, and every patch-element reply names the element's id. Studio sets the id on the preview only from that reply, so there is nothing to roll back. nextUniqueId moves to sourceMutation as the one owner (split and duplicate already used it).
…st-utils header true
|
@terencecho @somanshreddy thanks, both findings were real and had one root: the code choosing the id could see only the preview, not the file. Fixed at 67381aa by moving the choice to the server instead of patching the rollback.
Server tests cover |
terencecho
left a comment
There was a problem hiding this comment.
At 67381aaf, the two same-element failures in my earlier review are fixed: the client no longer installs/removes a speculative preview id, and ensure-id preserves an already-saved id even if its response was lost. I cannot lift changes requested yet because this fix leaves a cross-file ID collision that the previous head avoided.
Blocker — overlapping adds in different composition files can persist the same auto-ID into one preview document. Select an id-less div with a non-unique selector in composition A and start Add effect; before its ensure-id response arrives, select a similar element in inlined composition B and start another. ensureElementAddressable now only proposes div (gsapScriptCommitHelpers.ts:22–29), and useGsapAnimationOps.ts:128–145 does not reserve an ID on the shared preview DOM until after each request. Both calls can therefore propose div. The mutation coordinator queues per targetPath, so A and B do not serialize (studioFileMutationCoordinator.ts:9–21). Each server ensure-id checks only its parsed source file via nextUniqueId(document, ...) (studio-server/src/helpers/sourceMutation.ts:295–297,326–330), accepts div, and returns it. Both source files now contain id="div"; a preview with inlined subcompositions puts them in the same document. On the prior reviewed head, the first proposal synchronously installed its ID on the preview (gsapScriptCommitHelpers.ts:30–38 at 497ed032), so the second proposed div-2 instead.
This is not just an HTML-validity concern: when B is selected, writeTargetSelector prefers selection.id and returns #div (gsapShared.ts:271–280); MotionPathOverlay passes it to useMotionPathData (MotionPathOverlay.tsx:96–99), whose iframe-wide querySelector resolves A's first matching element (useMotionPathData.ts:142–153). The overlay can therefore measure the wrong element's home position and misplace B's motion-path edit. gsapRuntimeKeyframes.ts:330–347 similarly resolves the first document match before looking at timelines. I am not claiming ordinary composition-scoped GSAP playback targets the wrong element. Please maintain preview-wide uniqueness for generated IDs across the asynchronous save (without reintroducing the same-element cleanup bug), and test overlapping adds to separate inlined composition files with a source-unique editing read.
This is a source-level cross-file sequence; I did not run a live two-composition integration reproduction or local tests. The earlier required-check run passed, but a newer run was in progress when I checked. Rames's prior changes request remains a separate review at the old head.
— Review by tai (pr-review)
somanshreddy
left a comment
There was a problem hiding this comment.
Re-check at 67381aaf covering the 497ed032→67381aaf delta (4 commits); full PR not re-reviewed.
This is a comment, not an approval. Moving the id choice to the server fixes the three id items from the last round. I found no blocker of my own. I ran tai's new cross-file finding (5404129289) and confirm it; details under finding 1.
Prior items
| Item | Status | Evidence |
|---|---|---|
| (a) A refused add strips the id that an overlapping saved add gave the same element | Fixed | The rollback is gone. The client sets the id only from the reply (useGsapAnimationOps.ts:140-145). Two adds on one element both propose div. The server keeps the first one's id for the second (sourceMutation.ts:295-297). Pinned by "a refused add never strips…". |
| (b) A lost reply frees a saved id, and a sibling re-mints it (tai's blocker) | Fixed for siblings in the same file | ensure-id dedupes against the file, not the preview. A retry on the first element gets its saved div back, and the sibling gets div-2. Pinned by "a reply lost after the id is saved…", which runs the server's real patchElementInHtml. After a lost reply, the preview still lacks the saved id until the next add on that element or a reload. It's harmless for same-file siblings, but see finding 1 for other files. |
| (c) Title overclaim | Fixed | The title is now scoped to "patch and gsap writes". |
(d) Surviving mutants: write token, !res.ok |
Fixed | Both are caught now (D1, D2 below), by useGsapScriptCommits.test.tsx. |
Findings
1. Confirming tai (5404129289): overlapping adds in two different composition files can both save id="div" (medium, should-fix; a regression against base). Credit to tai. I checked each link:
- The dedupe only looks at the target file:
nextUniqueIdruns over the parsed target file (sourceMutation.ts:295-297,:326). - Sub-compositions share the preview document: the runtime appends mounted content into the host (
core/src/runtime/compositionLoader.ts:539/543). - The motion-path lookup searches the whole document:
useMotionPathData.ts:144andMotionPathOverlay.tsx:194callcontentDocument.querySelector("#div").
I ran a scratch hook test using the real patchElementInHtml for each file, with the first file's reply held. Both elements ended up id="div", both files saved id="div", querySelectorAll("#div").length === 2, and querySelector("#div") returned the first element.
It's a regression because base (b71aa9338, gsapScriptCommitHelpers.ts:40) and 497ed032 set the proposed id on the preview at mint time. A second mint made concurrently then saw it and took div-2. I confirmed that by reading the source; I didn't run the probe at the old head. Possible fixes: reserve in-flight proposals on the client (for example, a pending-id set that ensureElementAddressable also checks), or have the server dedupe across the composition files that share the preview. Runtime GSAP scripts are wrapped per composition root (compositionScoping.ts), so playback is probably scoped. I haven't verified that.
2. Nit: elementId reports the target, but ensure-id writes opTarget. sourceMutation.ts:321 returns htmlEl's id. If a caller ever sends childSelector, the reply would name the parent's id. The batch routes also accept ensure-id but never return elementId. Studio sends neither today.
Answers to the delta questions
- Serialization: read, patch and write run synchronously in one handler (
files.ts:3036-3108), with a compare-and-swap re-read at:528and 409 → re-read, re-mint and retry. Two in-process requests, from two tabs or two elements, can't interleave on one file. - Namespace and case: a scratch probe with linkedom deduped ids inside
<template>, nested<template>and SVG (div→div-2). It treatsDIVanddivas distinct, which is the same rule as the client'sgetElementById. A quote in the value comes out escaped.ensure-idskipsisSafeAttributeValue, but foridthat check always returns true, so nothing is lost. - Shared helper: I compared the new
nextUniqueIdagainst the 4 implementations it replaces (duplicate, freeze, split, group) on 28 inputs, including template and empty-base cases. The outputs are identical. - Route:
elementIdis added to both replies. It is omitted when not matched, and the client'sif (elementId)treats that as not found and shows a new toast.
Mutation results (17 run, 17 caught, 0 survived)
I ran the server set (5 files, 217 tests) and the studio set (4 changed hook files, 68 tests). The tree was clean after each revert.
- Server: S1 writes the proposal without dedupe. S2 overwrites an existing id. S3 changes
while→ifinnextUniqueId. S4 starts the suffix at 1. S5 and S6 dropelementIdfrom the route's unchanged and changed replies. S7 dropselementIdfrom the helper's unchanged return. - Client: C1 builds the tween selector from the proposal. C2 never sets the saved id on the preview. C3 sets the proposal instead. C4 sends
html-attribute. C5 falls back to the proposal when the reply names no id. C6 restores the live mint. C7 drops thepidguard. - Old survivors: D1 drops the
!res.okthrow (was M9). D2 drops the write token (was M10). D3 drops the refused-write toast.
CI at 67381aaf
At posting time, gh pr checks shows 72 pass and 22 pending, including Test (studio), 20 edit-accuracy shards, the timeline viewport gate and the global-install smoke test. The check-runs API shows 144 success, 23 in progress, 0 skipped and 0 failed. None are red so far, but CI isn't finished.
What I ran
- Control runs: studio-server
src(67 files, 1075 passed, 2 skipped) and studiosrc/hooks(145 files, 1400/1400), withNODE_ENV=test. - Scratch probes (since deleted): namespace and case, helper equivalence, and the cross-file overlap.
- The 17 mutants above.
- Own pass only; no Codex.
— Somu
jrusso1020
left a comment
There was a problem hiding this comment.
Requesting changes at 67381aaf. My blocker from 497ed032 is fixed. One regression from the fix remains: tai found it, and Somu reproduced it.
My blocker is fixed. The client no longer sets or removes an id before the write. It proposes autoId through ensure-id, the server keeps any id the element already has in the file or writes nextUniqueId against the file, and the client applies the reported elementId. So a third add can no longer overwrite a saved id, and the #div-2 tween keeps its target. The test "a refused add never strips the id an overlapping saved add gave the same element" holds the refused write while the second add completes, so the two really overlap. On the server, "keeps an id the element already holds and changes nothing" pins the no-overwrite rule.
Remaining blocker: two adds in different composition files can save the same id. I confirmed tai's sequence in the source:
- main and
497ed032reserved the proposed id on the preview element as soon as it was minted (gsapScriptCommitHelpers.ts:40), so a second add anywhere in the preview minteddiv-2. This head only proposes the id. serializeStudioFileMutationqueues per file, so writes to two files don't wait for each other.nextUniqueIdsees only the file being written.
So if two adds on id-less elements in different composition files overlap, both save id="div". Somu reproduced that with a scratch test. useMotionPathData then resolves #div with a document-wide querySelector and can measure the wrong element. The same happens with a lost response: file A saves div, the preview never learns it, and a later add in file B proposes div again. It needs tight timing or a lost reply, but main didn't do this.
To clear this, any one of these works:
- Reserve pending and saved ids on the client: propose against the preview DOM plus a set of in-flight proposals, and add the saved
elementIdto it. That brings back main's preview-wide uniqueness without the strip-on-failure step that caused my blocker. - Have the server dedupe across the project's composition files.
- Add the regression test tai asked for: overlapping adds in two inlined composition files get different ids.
A source-file filter in useMotionPathData, like the one studioMotion.ts resolveTarget already uses, would also make the overlay safe against ids an author repeats across sub-compositions. That's a nice-to-have, not a substitute for the fix.
Nit (carried over): elementId belongs to whatever element findTargetElement resolved. A selection without an hfId that falls back to selector[selectorIndex] could pick a different element and copy its id onto the selected one in the preview. Somu's childSelector note is the same kind of mismatch.
Ran: studio hooks tests (useGsapAnimationOps, useDomEditCommitsHelpers, useGsapScriptCommits, newTweenTarget) 68/68, and studio-server sourceMutation, files, duplicateElement and freezeFrame 176/176. CI at this head was still running when I posted (72 passed, 22 pending, none failed).
— Rames
|
@terencecho @jrusso1020 @somanshreddy thanks, the cross-file collision was real. Fixed at
Not changed here, and why:
|
terencecho
left a comment
There was a problem hiding this comment.
Approving 25a555ce. My blocker from 67381aaf (two adds in different composition files both saving id="div") is fixed at the server, where the collision was being decided.
What I verified
ensure-idonpatch-elementnow also avoids every id found in the project's HTML files:projectHtmlIds(routes/files.ts:~379–395) feedsnextUniqueId(document, base, takenIds)(sourceMutation.ts:~327–335). The set is built inside the retry loop atfiles.ts:~3085, right beforepatchElementInHtml, so a 409 retry re-reads it.- It is race-safe for the case I described: the read of the target file, the id scan, the patch and
writeMutationResult(snapshotBeforeWrite→readFileSynccompare →writeFileWithReceipt,files.ts:538–553) have noawaitbetween them, so a second add in another file runs after the first write is on disk and sees its id. The new test drives that sequence (file b getsdiv, then file a getsdiv-2). - The id regex errs toward over-reserving (it can match inside scripts or comments, which only yields
div-2, never a collision), and the second test pins its edge cases (quote-ended string, no space beforeid). Over-reserving does not affect the client: it uses the returned id for the preview and tween selector, as before. ensure-idhas one shipped producer (useDomEditCommitsHelpers.ts:229, viapatch-element), so the motion-path overlay's document-wide#idlookups can no longer land on a duplicate created by this flow.
Non-blocking
patch-element-batchesaccepts the same operation shape and callspatchElementInHtmlwithouttakenIds(files.ts:370), so an API caller sendingensure-idthere still gets file-local uniqueness. Nothing in the repo does that today; passingtakenIdsthere (or rejectingensure-idin batches) would close it.- Each
ensure-idrequest synchronously reads every project HTML file. That is fine for the rare Add-effect gesture, but worth caching if it ever moves onto a hotter path.
I read the code and tests but did not run them locally. At review time the 8 required checks present had passed and nothing was red, but 32 checks were still running (including edit-accuracy), so this approval is on code merit, not CI completion. Rames's and my earlier changes-requested reviews are on older heads; mine is superseded by this one.
— Review by tai (pr-review)
somanshreddy
left a comment
There was a problem hiding this comment.
Re-check at 25a555ce covering the 67381aaf→25a555ce delta (4 commits, +97/−5); full PR not re-reviewed. This is a comment, not an approval.
The cross-file duplicate id from tai's CR 5404129289 (I confirmed it in 5404145186) is fixed. ensure-id now also avoids every id found in the project's .html files (files.ts projectHtmlIds, passed to nextUniqueId as takenIds). In the patch-element handler there's no await between the read, the scan, the patch and writeMutationResult, so two adds handled by one server can't interleave. The 409 retry re-scans. The scan may reserve too much (an id= inside a script or a title also counts), but that's harmless: it only makes the suffix larger.
Lows (none block):
- Only double-quoted ids are tested. Changing
m[1] ?? m[2] ?? m[3]tom[1], which drops single-quoted and unquoted ids from another file, still passes. One more row in the "another file's ids" test would pin it. - Other routes still pick ids from one file only.
patch-element-batches(files.ts:370, as tai noted) doesn't passtakenIds, and neither do the duplicate, freeze, split and group callers ofnextUniqueId. That was already true on main and isn't a regression. Studio sendsensure-idonly through the single route. - Each add now reads every project HTML file synchronously on the request thread. That's fine for normal projects, but worth knowing if a project keeps many generated
.htmlfiles outside dot-folders.
Mutations (7 run, 5 caught):
- Caught: dropping
takenIds?.has, passingundefinedfrom the route, the quote/slash prefix class, the consuming-vs-lookahead value match (the 25a555c case), and gating the scan off. - Survived: the
m[1]-only capture (low 1) and dropping the hidden/vendor filter. The vendor filter only affects over-reservation, so its survival is expected.
Tests: files.test.ts plus sourceMutation.test.ts 161/161 locally (--environment node). At posting time CI shows 60 pass, 30 pending (including edit-accuracy), 1 skipping, none red.
— Somu
jrusso1020
left a comment
There was a problem hiding this comment.
Approving at 25a555ce. The cross-file blocker from my review at 67381aaf (tai's finding, which Somu reproduced) is fixed. My earlier same-element blocker stays fixed.
Cross-file ids. When the request carries ensure-id, the patch-element route now collects every id in the project's HTML files (projectHtmlIds) and passes them to nextUniqueId. A proposal that another composition file already holds moves on to div-2.
- Overlapping requests: inside the attempt loop there's no
awaitbetweenprojectHtmlIds,patchElementInHtmlandwriteMutationResult. So in one server process, two overlapping adds to different files can't both read the project before either writes. The second one sees the first one's id. - Lost reply: file A saves
div, the preview never learns it, and file B proposesdiv. The server seesdivin file A and returnsdiv-2. - Tests: "never lets ensure-id save an id another composition file in the preview holds" is the two-file case I asked for. The quote and slash cases pin the regex.
- Regex: a false match only costs an extra suffix. What matters is a real id it misses. It matches
idafter whitespace, a quote or/, with quoted or unquoted values, case-insensitive. It skipsdata-id, which isn't an id. I couldn't come up with a valid attribute it misses.
Nits, not blocking
- The batch routes (
patch-element-batches,patch-elements-batch) still callpatchElementInHtmlwithouttakenIds, so anensure-idsent there is deduped only within its file. Studio sendsensure-idonly through the single-element route today. projectHtmlIdswalks and reads every project HTML file on each ensure-id request. That's fine at project sizes, but it's the first place to look if "Add effect" gets slow on a large project.- Carried over:
elementIdbelongs to whatever elementfindTargetElementresolved, and Somu'schildSelectornote is the same kind of mismatch.
Ran: studio-server sourceMutation, files, duplicateElement, freezeFrame and safePath 185/185 at this head. The client-side code hasn't changed since 67381aaf, where the studio hooks tests passed 68/68. CI at this head: 94 passed, none failed, including the edit-accuracy gate. tai has approved this head, and Somu's comment finds nothing blocking.
— Rames
What
"Add effect" on an element with no id of its own no longer lets two elements share an id, and Studio's
patch-elementandgsap-mutationswrites each go through one client.patch-elementoperation,ensure-id, fixes both: the server keeps the element's existing id, or writes the proposed id made unique against every HTML file in the project. Sub-compositions share one preview document, so an id another composition file holds counts as taken. The scan, the patch and the write run in one synchronous step, so two adds in different files can't both pick the same id. Everypatch-elementreply now names the element's id (elementId). Studio sets that id on the preview only once the reply arrives, so nothing needs rolling back. This is the same split already used for clip split, duplicate and freeze frame, where the server picks the id.assignGsapTargetAutoIdIfNeededbuilt its ownfile-mutations/patch-elementrequest. It now posts throughpostPatchElementand returns the id the file holds, ornull. A refusal is toasted with the server's reason. A reply naming no id (the element isn't in the file) now toasts "Couldn't assign element id: element not found in "; main returned silently there.mutateGsapScript,mutateGsapScriptBatchandpostGsapMutationeach built the same POST.requestGsapMutationingsapMutationClient.tsis now the one request, and each caller keeps its own response handling.nextUniqueIdinsourceMutation.tsnow serves split, duplicate, freeze frame, group andensure-id. Before, there were four copies of the samebase,base-2, ... loop.Why
Review of the first version found both id bugs:
id="div".67381aaf). Each file was deduped alone, so both could saveid="div"into one preview. A lost reply in one file did the same to a later add in another.Both bugs come from one place: the code choosing the id could not see the file. Two copies of one request also drift apart; they already handled a refusal differently.
How
ensure-id.Before
An id-less element whose id write the server refuses (a forced 409 "file changed" on
patch-element), on main:After
The same refusal on this branch (captured before the
ensure-idchange; the refusal path and its toast are unchanged by it):Test plan
useGsapAnimationOps.test.tsxreplays each sequence through the realpatchElementInHtmlagainst an in-memory file:divanddiv-2, and the sibling's effect targets#div-2. With the previous Studio code the file helddivtwice.div-2.ensure-id:sourceMutation.test.ts: writes the proposed id, makes it unique against the file, keeps an existing id unchanged, reports no id for a missing target.files.test.ts: the route returnselementIdon both the changed and the unchanged reply, and an id saved incompositions/b.htmlmakes the same proposal inindex.htmlsave asdiv-2. That test fails at67381aaf, and fails again if the scan is dropped or limited to root files. A second test puts ids in another file straight after a quote and after a string ending in"/id=; both must count.useDomEditCommitsHelpers.test.ts: request shape (method, write token,ensure-idbody), the returned id,nullplus a toast for a missing target or a reply without an id, the server's reason on a refusal, a non-finite target refused before any request, a network failure passed through.useGsapScriptCommits.test.tsx: the write token on the gsap request, and a refused write's reason reaching a toast. Removing the token or the refusal check now fails a test (both passed the whole hooks suite before).patch-elementanswered with a 409 by request interception, "+ Add effect" on the first of two id-less.cardelements.Follow-ups, not in this PR:
addGsapAnimationnormally, so the add handler resets an element's manual path offset although no effect was added (useGsapSelectionHandlers.ts).file-mutations/remove-elementsstill has two inline clients (useTimelineDeleteOps.ts,useElementLifecycleOps.ts) that handle a refusal differently.nextUniqueId, which theensure-idand split tests pin.