Skip to content

Commit 49c4950

Browse files
fix(studio-server): an edit aimed at a clip never lands on an audio group sharing its id (#5645)
* fix(studio-server): an edit aimed at a clip never lands on an audio group sharing its id * refactor(studio-server): one comment line, the rest in names
1 parent 695110e commit 49c4950

2 files changed

Lines changed: 27 additions & 9 deletions

File tree

‎packages/studio-server/src/helpers/sourceMutation.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,18 @@ describe("T7 — data-hf-id targeting (spec for R1)", () => {
603603
expect(html).toMatch(/color:\s*blue/);
604604
});
605605

606+
it("never falls back by id onto an element pinned to another hfId", () => {
607+
// A clip in a sub-composition shares the audio group's id; its edit must not land on the group.
608+
const source = `<div data-composition-id="main"><hf-audio-group id="voice" data-hf-id="hf-oova" data-fx-chain="[]"></hf-audio-group><div class="clip" data-start="0"></div></div>`;
609+
const target = { id: "voice", hfId: "hf-6egx" };
610+
expect(removeElementFromHtml(source, target)).toBe(source);
611+
const { html, matched } = patchElementInHtml(source, target, [
612+
{ type: "attribute", property: "data-start", value: "3" },
613+
]);
614+
expect(matched).toBe(false);
615+
expect(html).toBe(source);
616+
});
617+
606618
it("does not break out of the selector on a crafted hfId (CSS injection guard)", () => {
607619
// A value with a quote/bracket must be escaped, not injected — it should
608620
// simply match nothing and leave the source untouched, never throw.

‎packages/studio-server/src/helpers/sourceMutation.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,22 @@ export interface SourceMutationTarget {
2323
selectorIndex?: number;
2424
}
2525

26+
const pinnedInSource = new WeakMap<Document, ReadonlySet<string>>();
27+
2628
export function parseSourceDocument(raw: string): {
2729
document: Document;
2830
wrappedFragment: boolean;
2931
} {
3032
const source = ensureHfIds(raw);
3133
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(source);
32-
if (hasDocumentShell) {
33-
return { document: parseHTML(source).document, wrappedFragment: false };
34-
}
35-
return {
36-
document: parseHTML(`<!DOCTYPE html><html><head></head><body>${source}</body></html>`).document,
37-
wrappedFragment: true,
38-
};
34+
const document = hasDocumentShell
35+
? parseHTML(source).document
36+
: parseHTML(`<!DOCTYPE html><html><head></head><body>${source}</body></html>`).document;
37+
pinnedInSource.set(
38+
document,
39+
new Set(Array.from(raw.matchAll(/\bdata-hf-id\s*=\s*["']?([^"'\s>]+)/gi), (m) => m[1]!)),
40+
);
41+
return { document, wrappedFragment: !hasDocumentShell };
3942
}
4043

4144
function duplicateCssRulesForId(document: Document, originalId: string, newId: string): void {
@@ -123,16 +126,19 @@ export function findTargetElement(
123126
const el = findByHfId(document, target.hfId);
124127
if (el) return el;
125128
}
129+
const pinned = pinnedInSource.get(document);
130+
const unlessPinnedElsewhere = (el: Element | null | undefined) =>
131+
el && !(target.hfId && pinned?.has(el.getAttribute("data-hf-id") ?? "")) ? el : null;
126132

127133
if (target.id) {
128-
const byId = document.getElementById(target.id);
134+
const byId = unlessPinnedElsewhere(document.getElementById(target.id));
129135
if (byId) return byId;
130136
}
131137

132138
if (!target.selector) return null;
133139
try {
134140
const matches = querySelectorAllWithTemplates(document, target.selector);
135-
return matches[target.selectorIndex ?? 0] ?? null;
141+
return unlessPinnedElsewhere(matches[target.selectorIndex ?? 0]);
136142
} catch {
137143
return null;
138144
}

0 commit comments

Comments
 (0)