From 40c28e10dbca440c9707ff8f66b0425cd78913af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 28 Oct 2026 13:30:46 +0000 Subject: [PATCH] fix(studio): a timeline edit on a clip the file lacks says how the clip was looked up (#6661) * fix(studio): a timeline edit on a clip the file lacks says how the clip was looked up * fix(studio): name only the lookups a missing clip was really found by * refactor(studio): split the missing-clip lookup words into small helpers * refactor(studio): fit the missing-clip lookup words under the no-visible-change size --- .../src/hooks/timelineEditingHelpers.test.ts | 25 +++++++++++++++++++ .../src/hooks/timelineEditingHelpers.ts | 18 ++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts index e4f8450777..18b76d0595 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts @@ -414,6 +414,31 @@ describe("persistTimelineBatchEdit", () => { ).rejects.toThrow("Unable to patch timeline element ghost in index.html"); expect(writes).toHaveLength(0); }); + + it("names which lookups a missing clip had, never their values", async () => { + stubReadFileContent(SOURCE); + // As the timeline builds them: a clip with an id also carries `#id` as its selector. + const missing = (element: Partial) => { + const change = moveMember("ghost", 3, 0, 2); + change.element = { ...change.element, ...element }; + return persistTimelineBatchEdit(batchInput([change], [])); + }; + await expect( + missing({ + domId: "intro__hf2", + hfId: "hf-zz9q", + selector: "#intro__hf2", + sourceFile: "compositions/intro.html", + }), + ).rejects.toThrow("(by hf-id, runtime-renamed id; file from the clip)"); + await expect(missing({ selector: "#ghost" })).rejects.toThrow("(by id; no file on the clip)"); + await expect( + missing({ domId: undefined, selector: ".caption", selectorIndex: 4 }), + ).rejects.toThrow("(by class; no file on the clip)"); + await expect( + missing({ domId: undefined, selector: '[data-composition-id="intro__hf2"]' }), + ).rejects.toThrow("(by runtime-renamed composition id; no file on the clip)"); + }); }); describe("persistTimelineEdit", () => { diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 293bee50fc..82b739ce02 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -387,6 +387,20 @@ export function operationChanges( })); } +const RENAMED_MOUNT = /__hf\d+"?\]?$/; +const lookup = (value: string, kind: string) => + RENAMED_MOUNT.test(value) ? `runtime-renamed ${kind}` : kind; +const selectorLookup = (selector: string) => + selector.startsWith("[data-composition-id=") + ? lookup(selector, "composition id") + : /^\.[\w-]+$/.test(selector) && "class"; + +function missedLookups(element: Pick, target: PatchTarget): string { + const kinds = [target.hfId && "hf-id", target.id && lookup(target.id, "id")]; + const by = [...kinds, target.selector && selectorLookup(target.selector)].filter(Boolean); + return `by ${by.join(", ") || "nothing"}; ${element.sourceFile ? "file from the clip" : "no file on the clip"}`; +} + /** Patches each change into `source`, failing loudly on a target the file does not hold. */ export function patchTimelineChangesInSource( source: string, @@ -399,7 +413,9 @@ export function patchTimelineChangesInSource( if (!target) throw new Error(`Timeline element ${element.id} is missing a patchable target`); // Resolve first: a member already at its target values patches to the same string, a missing one must throw. if (!findTagByTarget(current, target)) { - throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`); + throw new Error( + `Unable to patch timeline element ${element.id} in ${targetPath} (${missedLookups(element, target)})`, + ); } current = buildPatches(current, target); }