fix(studio): per-child patch op builders and persist-seam harness (#1909)

* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)

* fix(studio): per-child patch op builders and persist-seam harness

- buildTextFieldChildLocator indexes over the parent's full same-tag child list
- buildTextFieldChildOperations emits per-field ops for same-shape multi-field edits
- SDK cutover declines child-scoped batches (hfId mapping would hit the parent)
- persist-seam integration harness drives real client ops through patchElementInHtml

* fix(studio): fail closed on unresolved text-field child index

buildTextFieldChildLocator guessed a synthetic field's position by
counting same-tag "child" fields elsewhere in the array whenever
sourceChildIndex was absent. That heuristic is unreachable today (the
count-mismatch guard in buildTextFieldChildOperations already refuses
add/remove edits before it's reached) but would silently locate the
wrong element for a future caller that wires up synthetic-field
support without also computing a real sourceChildIndex. Return null
instead so the caller falls back to the unsupported-structure path.
This commit is contained in:
Miguel Ángel
2026-07-03 18:14:17 -07:00
committed by GitHub
parent fb13797d2f
commit 22942280b6
12 changed files with 587 additions and 21 deletions
+9
View File
@@ -264,6 +264,10 @@
// require intrusive middleware changes beyond this PR's scope.
"minLines": 6,
"ignore": [
// sourcePatcher.ts: pre-existing internal clones between the inline-style
// and attribute tag-patchers; only the PatchOperation type gained two
// optional fields here, but the line shift makes fallow re-flag them.
"packages/studio/src/utils/sourcePatcher.ts",
// gsapParser.ts: recast/babel GSAP writer — intentional duplication between
// recast and acorn parallel implementations (pre-existing, moved from core).
"packages/parsers/src/gsapParser.ts",
@@ -364,6 +368,11 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
// patch*InTag pre-date this PR; only the PatchOperation type gained two
// optional fields, but the line-shift fingerprint re-flags the inherited
// complexity.
"packages/studio/src/utils/sourcePatcher.ts",
// timeline.ts: collectRuntimeTimelinePayload (CRITICAL) pre-dates this PR;
// only an import line changed here (slideshow/sceneId → slideshow/index),
// but the line-shift fingerprint makes fallow re-flag inherited complexity.
@@ -31,6 +31,7 @@ export {
buildDomEditTextPatchOperation,
collectDomEditLayerItems,
countDomEditChildLayers,
buildTextFieldChildLocator,
getDomEditLayerKey,
getDomEditNonEditableReason,
getDomEditTargetKey,
@@ -4,11 +4,27 @@ import {
collectDomEditLayerItems,
resolveDomEditSelection,
buildDomEditPatchTarget,
buildTextFieldChildLocator,
readHfId,
} from "./domEditingLayers";
import type { DomEditTextField } from "./domEditingTypes";
const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
function textField(overrides: Partial<DomEditTextField> = {}): DomEditTextField {
return {
key: "child:0:span",
label: "Text 1",
value: "Hello",
tagName: "span",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "child",
...overrides,
};
}
describe("buildDomEditPatchTarget", () => {
it("includes hfId when selection has hfId", () => {
const target = buildDomEditPatchTarget({
@@ -172,3 +188,39 @@ describe("resolveDomEditSelection — data-hf-group capture", () => {
expect(selection?.id).toBe("outside");
});
});
describe("buildTextFieldChildLocator", () => {
it("locates a child field using its DOM-derived sourceChildIndex", () => {
const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })];
expect(buildTextFieldChildLocator(fields, "child:0:span")).toEqual({
childSelector: ":scope > span",
childIndex: 0,
});
});
it("fails closed for a synthetic child field with no sourceChildIndex", () => {
// A field built by buildDefaultDomEditTextField (e.g. "add text field")
// has never been read back from the live DOM, so its true position among
// same-tag siblings is unknown. Guessing it by counting same-tag "child"
// fields elsewhere in the array can silently point at the wrong element.
const fields = [
textField({ key: "child:0:span", sourceChildIndex: 0 }),
textField({ key: "child:new:1", tagName: "span" }),
];
expect(buildTextFieldChildLocator(fields, "child:new:1")).toBeNull();
});
it("returns null for a self-sourced field", () => {
const fields = [textField({ key: "self:0:div", source: "self", sourceChildIndex: 0 })];
expect(buildTextFieldChildLocator(fields, "self:0:div")).toBeNull();
});
it("returns null for an unknown field key", () => {
const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })];
expect(buildTextFieldChildLocator(fields, "missing")).toBeNull();
});
});
@@ -1,7 +1,3 @@
/**
* Layer items, text fields, capabilities, selection resolution, and patch operations
* for dom editing.
*/
import type { PatchOperation } from "../../utils/sourcePatcher";
import {
resolveEditingAffordances,
@@ -36,12 +32,20 @@ import {
} from "./domEditingElement";
import { isCompositionRootLayer } from "./domEditingRootLayer";
// ─── Text fields ────────────────────────────────────────────────────────────
export function isEditableTextLeaf(el: HTMLElement): boolean {
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
}
function sameTagChildIndex(el: HTMLElement): number {
let index = 0;
let sibling = el.previousElementSibling;
while (sibling) {
if (sibling.tagName === el.tagName) index += 1;
sibling = sibling.previousElementSibling;
}
return index;
}
function getTextFieldLabel(
_tagName: string,
index: number,
@@ -57,6 +61,7 @@ function buildTextField(
index: number,
total: number,
source: "self" | "child",
sourceChildIndex?: number,
): DomEditTextField {
const tagName = el.tagName.toLowerCase();
const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`;
@@ -74,6 +79,7 @@ function buildTextField(
inlineStyles: getInlineStyles(el),
computedStyles: getCuratedComputedStyles(el),
source,
...(sourceChildIndex == null ? {} : { sourceChildIndex }),
};
}
@@ -105,7 +111,9 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
});
childIdx++;
} else if (isHtmlElement(node) && isEditableTextLeaf(node)) {
fields.push(buildTextField(node, childIdx, childElements.length, "child"));
fields.push(
buildTextField(node, childIdx, childElements.length, "child", sameTagChildIndex(node)),
);
childIdx++;
}
}
@@ -113,7 +121,7 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] {
}
return childElements.map((child, index) =>
buildTextField(child, index, childElements.length, "child"),
buildTextField(child, index, childElements.length, "child", sameTagChildIndex(child)),
);
}
@@ -172,14 +180,30 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
};
}
// ─── Capabilities ────────────────────────────────────────────────────────────
export interface DomEditChildLocator {
childSelector: string;
childIndex: number;
}
export function buildTextFieldChildLocator(
fields: DomEditTextField[],
fieldKey: string,
): DomEditChildLocator | null {
const field = fields.find((candidate) => candidate.key === fieldKey);
if (!field || field.source !== "child") return null;
// sourceChildIndex is only absent for a synthetic field that was never read
// back from the live DOM (e.g. one built by buildDefaultDomEditTextField).
// Guessing its position by counting same-tag "child" fields elsewhere in
// the array is unreliable and can silently locate the wrong element — fail
// closed instead so the caller falls back to the unsupported-structure path.
if (field.sourceChildIndex == null) return null;
return {
childSelector: `:scope > ${field.tagName}`,
childIndex: field.sourceChildIndex,
};
}
/**
* Build the geometry/capability half of EditableElementFacts. Section inputs
* (text/timing/animation) are irrelevant to capability resolution, so they are
* zeroed here. Shared by the wrapper and the live-selection path so the two
* fact-construction sites can't disagree.
*/
function capabilityFacts(geometry: {
hasStableTarget: boolean;
tag: string;
@@ -276,8 +300,11 @@ async function probeSourceElement(
},
);
if (!response.ok) return true;
const data = (await response.json()) as { exists?: boolean };
return data.exists !== false;
const data = await response.json();
if (data && typeof data === "object" && "exists" in data && data.exists === false) {
return false;
}
return true;
} catch {
return true;
}
@@ -475,19 +502,28 @@ export function collectDomEditLayerItems(
// ─── Patch operations ────────────────────────────────────────────────────────
export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation {
export function buildDomEditStylePatchOperation(
property: string,
value: string | null,
childLocator?: DomEditChildLocator,
): PatchOperation {
return {
type: "inline-style",
property,
value,
...childLocator,
};
}
export function buildDomEditTextPatchOperation(value: string): PatchOperation {
export function buildDomEditTextPatchOperation(
value: string,
childLocator?: DomEditChildLocator,
): PatchOperation {
return {
type: "text-content",
property: "text",
value,
...childLocator,
};
}
@@ -69,6 +69,7 @@ export interface DomEditTextField {
inlineStyles: Record<string, string>;
computedStyles: Record<string, string>;
source: "self" | "child" | "text-node";
sourceChildIndex?: number;
}
export interface DomEditSelection extends PatchTarget {
@@ -0,0 +1,264 @@
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
patchElementInHtml,
type PatchOperation,
type SourceMutationTarget,
} from "@hyperframes/studio-server/source-mutation";
import { describe, expect, it } from "vitest";
import {
collectDomEditTextFields,
buildDomEditPatchTarget,
buildDomEditStylePatchOperation,
buildDomEditTextPatchOperation,
} from "./domEditingLayers";
import { buildPathOffsetPatches } from "./manualEditsDomPatches";
import { STUDIO_OFFSET_X_PROP, STUDIO_PATH_OFFSET_ATTR } from "./manualEditsTypes";
import { makeSelection } from "../../hooks/domSelectionTestHarness";
import { buildTextFieldChildOperations } from "../../hooks/domEditTextFieldCommitOps";
const testDir = dirname(fileURLToPath(import.meta.url));
const fixtureDir = join(testDir, "../../../tests/e2e/fixtures/design-panel-qa");
function readFixture(relativePath: string): string {
return readFileSync(join(fixtureDir, relativePath), "utf-8");
}
function createSelection(input: {
id: string;
hfId: string;
tagName: string;
}): ReturnType<typeof makeSelection> {
const element = document.createElement(input.tagName);
element.id = input.id;
element.setAttribute("data-hf-id", input.hfId);
return {
...makeSelection(input.id, element),
hfId: input.hfId,
};
}
function clientTarget(input: { id: string; hfId: string; tagName: string }): SourceMutationTarget {
return buildDomEditPatchTarget(createSelection(input));
}
function patchAndExpectChange(
sourceHtml: string,
target: SourceMutationTarget,
operations: PatchOperation[],
): string {
const result = patchElementInHtml(sourceHtml, target, operations);
expect(result.matched).toBe(true);
expect(result.html).not.toBe(sourceHtml);
return result.html;
}
function parseHtml(html: string): Document {
return new DOMParser().parseFromString(html, "text/html");
}
function findElementInHtml(html: string, selector: string): Element {
const document = parseHtml(html);
const directMatch = document.querySelector(selector);
if (directMatch) return directMatch;
for (const template of Array.from(document.querySelectorAll("template"))) {
const templateMatch = template.content.querySelector(selector);
if (templateMatch) return templateMatch;
}
throw new Error(`Expected selector ${selector} to match`);
}
function findByHfId(html: string, hfId: string): Element {
return findElementInHtml(html, `[data-hf-id="${hfId}"]`);
}
function countOccurrences(value: string, needle: string): number {
return value.split(needle).length - 1;
}
describe("persist seam source mutation", () => {
const indexHtml = readFixture("index.html");
const subHtml = readFixture("compositions/qa-sub.html");
it("persists qa-headline text font-size style operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-headline", hfId: "qa-headline", tagName: "h1" }),
[buildDomEditStylePatchOperation("font-size", "64px")],
);
expect(findByHfId(html, "qa-headline").getAttribute("style")).toContain("font-size: 64px");
});
it("persists qa-shape fill style operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
[buildDomEditStylePatchOperation("background-color", "#ff0000")],
);
expect(findByHfId(html, "qa-shape").getAttribute("style")).toContain(
"background-color: #ff0000",
);
});
it("persists qa-multi text color style operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-multi", hfId: "qa-multi", tagName: "div" }),
[buildDomEditStylePatchOperation("color", "#00ff00")],
);
expect(findByHfId(html, "qa-multi").getAttribute("style")).toContain("color: #00ff00");
});
it("persists qa-image opacity style operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-image", hfId: "qa-image", tagName: "img" }),
[buildDomEditStylePatchOperation("opacity", "0.4")],
);
expect(findByHfId(html, "qa-image").getAttribute("style")).toContain("opacity: 0.4");
});
it("persists detached jsdom path offset operations", () => {
const element = document.createElement("div");
element.style.setProperty(STUDIO_OFFSET_X_PROP, "24px");
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }),
buildPathOffsetPatches(element),
);
const shape = findByHfId(html, "qa-shape");
expect(shape.getAttribute("style")).toContain(`${STUDIO_OFFSET_X_PROP}: 24px`);
expect(shape.getAttribute("style")).toContain("translate: var(--hf-studio-offset-x, 0px)");
expect(shape.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true");
});
it("persists timeline data-start attribute operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-zone-headline", hfId: "qa-zone-headline", tagName: "div" }),
[{ type: "attribute", property: "start", value: "2.5" }],
);
expect(findByHfId(html, "qa-zone-headline").getAttribute("data-start")).toBe("2.5");
expect(countOccurrences(html, 'data-start="2.5"')).toBe(1);
});
it("persists media volume data attribute operation", () => {
const html = patchAndExpectChange(
indexHtml,
clientTarget({ id: "qa-video", hfId: "qa-video", tagName: "video" }),
[{ type: "attribute", property: "volume", value: "0.75" }],
);
expect(findByHfId(html, "qa-video").getAttribute("data-volume")).toBe("0.75");
expect(html).not.toContain('data-volume="0.5"');
});
it("returns matched false and unchanged html for a missing hfId target", () => {
const result = patchElementInHtml(indexHtml, { hfId: "qa-does-not-exist" }, [
buildDomEditStylePatchOperation("font-size", "64px"),
]);
expect(result.matched).toBe(false);
expect(result.html).toBe(indexHtml);
});
it("persists sub-composition child style operation inside a template", () => {
const html = patchAndExpectChange(
subHtml,
clientTarget({ id: "qa-sub-title", hfId: "qa-sub-title", tagName: "h2" }),
[buildDomEditStylePatchOperation("font-size", "50px")],
);
expect(findByHfId(html, "qa-sub-title").getAttribute("style")).toContain("font-size: 50px");
});
it("returns matched false for runtime-generated caption words absent from static source", () => {
const result = patchElementInHtml(
indexHtml,
{ selector: "#qa-caption-host span", selectorIndex: 0 },
[buildDomEditStylePatchOperation("color", "#ffffff")],
);
expect(result.matched).toBe(false);
expect(result.html).toBe(indexHtml);
});
it("fixes U4: child text-field style persists as an inline style on the correct child span", () => {
const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
buildDomEditStylePatchOperation("color", "#0000ff", {
childSelector: ":scope > span",
childIndex: 0,
}),
]);
const lineA = findElementInHtml(html, ".qa-line-a");
const lineB = findElementInHtml(html, ".qa-line-b");
expect(lineA.getAttribute("style")).toContain("color: #0000ff");
expect(lineB.getAttribute("style")).toBeNull();
expect(lineB.textContent).toBe("Second styled line");
expect(html).not.toContain("&lt;span");
});
it("targets the second direct child when siblings share the same tag and class", () => {
const source = `<div data-hf-id="dups"><span class="dup">First</span><span class="dup">Second</span></div>`;
const html = patchAndExpectChange(source, { hfId: "dups" }, [
buildDomEditStylePatchOperation("color", "#0000ff", {
childSelector: ":scope > span",
childIndex: 1,
}),
]);
const document = parseHtml(html);
const spans = Array.from(document.querySelectorAll(".dup"));
expect(spans[0]?.getAttribute("style")).toBeNull();
expect(spans[1]?.getAttribute("style")).toContain("color: #0000ff");
});
it("persists a child text-field content edit as plain text", () => {
const value = "A < B & C";
const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [
buildDomEditTextPatchOperation(value, {
childSelector: ":scope > span",
childIndex: 0,
}),
]);
expect(findElementInHtml(html, ".qa-line-a").textContent).toBe(value);
expect(findElementInHtml(html, ".qa-line-b").textContent).toBe("Second styled line");
expect(html).not.toContain("&lt;span");
});
it("uses same-tag source child indexes when a non-leaf sibling sits between fields", () => {
const source = `<div data-hf-id="mixed"><span class="leaf-a">First</span><span class="wrapper"><b>Wrapper</b></span><span class="leaf-b">Second</span></div>`;
const previewHost = document.createElement("div");
previewHost.innerHTML = source;
const previewTarget = previewHost.querySelector('[data-hf-id="mixed"]');
if (!(previewTarget instanceof HTMLElement)) throw new Error("Expected preview target");
const originalFields = collectDomEditTextFields(previewTarget);
const secondField = originalFields.find((field) => field.value === "Second");
if (!secondField) throw new Error("Expected second text field");
const nextFields = originalFields.map((field) =>
field.key === secondField.key ? { ...field, value: "Second updated" } : field,
);
const operations = buildTextFieldChildOperations(originalFields, nextFields);
if (!operations) throw new Error("Expected child operations");
const html = patchAndExpectChange(source, { hfId: "mixed" }, operations);
expect(findElementInHtml(html, ".leaf-a").textContent).toBe("First");
expect(findElementInHtml(html, ".wrapper").textContent).toBe("Wrapper");
expect(findElementInHtml(html, ".leaf-b").textContent).toBe("Second updated");
});
});
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import type { DomEditTextField } from "../components/editor/domEditing";
import { buildTextFieldChildOperations } from "./domEditTextFieldCommitOps";
function textField(input: {
key: string;
value: string;
tagName?: string;
inlineStyles?: Record<string, string>;
source?: DomEditTextField["source"];
sourceChildIndex?: number;
}): DomEditTextField {
return {
key: input.key,
label: input.key,
value: input.value,
tagName: input.tagName ?? "span",
attributes: [],
inlineStyles: input.inlineStyles ?? {},
computedStyles: {},
source: input.source ?? "child",
...(input.sourceChildIndex == null ? {} : { sourceChildIndex: input.sourceChildIndex }),
};
}
describe("buildTextFieldChildOperations", () => {
it("builds child-scoped text and style operations for changed child fields", () => {
const originalFields = [
textField({
key: "first",
value: "First",
inlineStyles: { color: "red" },
sourceChildIndex: 0,
}),
textField({
key: "second",
value: "Second",
inlineStyles: { "font-size": "24px" },
sourceChildIndex: 1,
}),
];
const nextFields = [
originalFields[0],
textField({
key: "second",
value: "Second < &",
inlineStyles: { "font-size": "24px", color: "#0000ff" },
sourceChildIndex: 1,
}),
];
expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([
{
type: "text-content",
property: "text",
value: "Second < &",
childSelector: ":scope > span",
childIndex: 1,
},
{
type: "inline-style",
property: "color",
value: "#0000ff",
childSelector: ":scope > span",
childIndex: 1,
},
]);
});
it("emits null for a removed inline style", () => {
const originalFields = [
textField({
key: "first",
value: "First",
inlineStyles: { color: "red" },
sourceChildIndex: 0,
}),
];
const nextFields = [
textField({ key: "first", value: "First", inlineStyles: {}, sourceChildIndex: 0 }),
];
expect(buildTextFieldChildOperations(originalFields, nextFields)).toEqual([
{
type: "inline-style",
property: "color",
value: null,
childSelector: ":scope > span",
childIndex: 0,
},
]);
});
it("returns null for structural changes, reordered fields, and text nodes", () => {
const originalFields = [
textField({ key: "first", value: "First" }),
textField({ key: "second", value: "Second" }),
];
expect(buildTextFieldChildOperations(originalFields, [originalFields[0]])).toBeNull();
expect(
buildTextFieldChildOperations(originalFields, [originalFields[1], originalFields[0]]),
).toBeNull();
expect(
buildTextFieldChildOperations(originalFields, [
originalFields[0],
textField({ key: "second", value: "Second", tagName: "#text", source: "text-node" }),
]),
).toBeNull();
});
});
@@ -0,0 +1,63 @@
import {
buildDomEditStylePatchOperation,
buildDomEditTextPatchOperation,
buildTextFieldChildLocator,
type DomEditTextField,
} from "../components/editor/domEditing";
import type { PatchOperation } from "../utils/sourcePatcher";
function hasSameKeysInSamePositions(
originalFields: DomEditTextField[],
nextFields: DomEditTextField[],
): boolean {
return originalFields.every((field, index) => nextFields[index]?.key === field.key);
}
function inlineStyleValue(styles: Record<string, string>, property: string): string | null {
return Object.prototype.hasOwnProperty.call(styles, property) ? styles[property] : null;
}
function inlineStyleProperties(
originalStyles: Record<string, string>,
nextStyles: Record<string, string>,
): string[] {
return Array.from(new Set([...Object.keys(originalStyles), ...Object.keys(nextStyles)]));
}
// fallow-ignore-next-line complexity
export function buildTextFieldChildOperations(
originalFields: DomEditTextField[],
nextFields: DomEditTextField[],
): PatchOperation[] | null {
if (originalFields.length !== nextFields.length) return null;
if (!hasSameKeysInSamePositions(originalFields, nextFields)) return null;
if (nextFields.some((field) => field.source === "text-node")) return null;
if (nextFields.some((field) => field.source !== "child")) return null;
if (originalFields.some((field) => field.source !== "child")) return null;
const originalByKey = new Map(originalFields.map((field) => [field.key, field]));
const operations: PatchOperation[] = [];
for (const nextField of nextFields) {
const originalField = originalByKey.get(nextField.key);
const locator = buildTextFieldChildLocator(originalFields, nextField.key);
if (!originalField || !locator) return null;
if (nextField.value !== originalField.value) {
operations.push(buildDomEditTextPatchOperation(nextField.value, locator));
}
for (const property of inlineStyleProperties(
originalField.inlineStyles,
nextField.inlineStyles,
)) {
const originalValue = inlineStyleValue(originalField.inlineStyles, property);
const nextValue = inlineStyleValue(nextField.inlineStyles, property);
if (nextValue !== originalValue) {
operations.push(buildDomEditStylePatchOperation(property, nextValue, locator));
}
}
}
return operations;
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import type { PatchOperation } from "./sourcePatcher";
import { shouldUseSdkCutover } from "./sdkCutoverEligibility";
const childStyleOp: PatchOperation = {
type: "inline-style",
property: "color",
value: "blue",
childSelector: ":scope > span",
childIndex: 0,
};
describe("shouldUseSdkCutover child-scoped operations", () => {
it("declines child-scoped operations because SDK patch ops target only the parent hfId", () => {
expect(shouldUseSdkCutover(true, true, "hf-parent", [childStyleOp])).toBe(false);
});
});
@@ -61,19 +61,23 @@ function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
);
}
function hasChildScopedOp(ops: PatchOperation[]): boolean {
return ops.some((op) => op.childSelector !== undefined);
}
function hasTextContentOp(ops: PatchOperation[]): boolean {
return ops.some((op) => op.type === "text-content");
}
function targetChildren(target: unknown): unknown[] | null {
if (!target || typeof target !== "object" || !("children" in target)) return null;
const children = (target as { children?: unknown }).children;
const children = target.children;
return Array.isArray(children) ? children : null;
}
function elementTag(element: unknown): string | null {
if (!element || typeof element !== "object" || !("tag" in element)) return null;
const tag = (element as { tag?: unknown }).tag;
const tag = element.tag;
return typeof tag === "string" ? tag.toLowerCase() : null;
}
@@ -110,6 +114,8 @@ export function shouldUseSdkCutover(
!!hfId &&
ops.length > 0 &&
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
// SDK edit ops target only the element hfId; child-scoped patch ops need the server path.
!hasChildScopedOp(ops) &&
!ops.some(mapsToReservedAttr) &&
!hasUnsafeHtmlAttributeOp(ops)
);
@@ -90,6 +90,8 @@ export interface PatchOperation {
type: "inline-style" | "attribute" | "text-content" | "html-attribute";
property: string;
value: string | null;
childSelector?: string;
childIndex?: number;
}
// Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
+4
View File
@@ -174,6 +174,10 @@ export default defineConfig({
resolve: {
alias: {
"@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"),
"@hyperframes/studio-server/source-mutation": resolve(
__dirname,
"../studio-server/src/helpers/sourceMutation.ts",
),
},
},
build: {