mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(studio): persist canvas z-order actions correctly for static elements
An adversarial review of the canvas context-menu z-order pipeline (Bring to Front / Forward / Backward / Send to Back) found the resolver math sound but the glue between the menu and the commit hook broken: - The menu optimistically wrote style.zIndex AND position: relative to the live elements BEFORE the commit hook ran. The hook decides whether to persist position by checking getComputedStyle(el).position === 'static' — always false after the pre-apply — so the position patch was never persisted on the menu path and the reorder silently reverted at the post-commit reload for any nested/static element (root clips survive only because the runtime forces position:absolute). The same pre-apply made the failure rollback capture the already-mutated values, restoring the broken state on persist errors. The menu no longer pre-applies; the hook owns the live writes (it already applied both synchronously) and now sees true priors. Siblings without a persistable identity still get their z applied live-only so a renumber stays visually coherent. - The commit hook's entry.key store-sync plumbing had zero production callers; the store zIndex went stale until full reload. All three callers (canvas menu via PreviewOverlays, timeline lane z-sync, LayersPanel) now derive and pass the timeline store key (new deriveTimelineStoreKey helper). - patchElementBatch discarded the server's per-patch matched[]; unresolvable siblings persisted partially and silently. Unmatched targets now warn and report save-failure telemetry (z-reorder-unmatched) without rolling back the matched subset. - template/noscript elements counted as painting siblings, so renumber fallbacks wrote z-index/position into <template> tags in the source file. Excluded from the sibling family. - The default undo coalesce key merged DISTINCT z actions within 300ms into one undo entry; the action kind is now part of the key (LayersPanel drags keep coalescing within a drag; explicit lane-move gesture keys untouched). - rectsIntersect comment claimed touching rects intersect; the strict inequalities say otherwise — comment fixed.
This commit is contained in:
@@ -344,6 +344,60 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
|
||||
// The server reports per-patch matched[]: #b was not found in the source.
|
||||
// The matched subset persisted, so the commit must complete (no rollback of
|
||||
// applied state) while surfacing the partial failure.
|
||||
const original = '<div id="a" style="z-index: 1"></div>';
|
||||
const after = '<div id="a" style="z-index: 2"></div>';
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
|
||||
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
||||
return jsonResponse({ ok: true, changed: true, matched: [true, false], content: after });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { iframe, element } = createPreviewElement(
|
||||
'<div data-hf-id="hf-card"></div><div id="b"></div>',
|
||||
);
|
||||
element.id = "a";
|
||||
const second = iframe.contentDocument!.getElementById("b")!;
|
||||
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await rendered.hook.handleDomZIndexReorderCommit([
|
||||
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
||||
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
|
||||
]);
|
||||
});
|
||||
|
||||
// No throw: the applied live state stays, the matched subset is recorded.
|
||||
expect(element.style.zIndex).toBe("2");
|
||||
expect(second.style.zIndex).toBe("1");
|
||||
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("could not match 1 patch target(s) in index.html"),
|
||||
"b",
|
||||
);
|
||||
expect(trackStudioEvent).toHaveBeenCalledWith(
|
||||
"save_failure",
|
||||
expect.objectContaining({
|
||||
mutation_type: "z-reorder-unmatched",
|
||||
file_path: "index.html",
|
||||
error_message: expect.stringContaining("b"),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
rendered.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back live state after a failed batch POST without a disk write-back", async () => {
|
||||
const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
|
||||
@@ -61,6 +61,34 @@ interface RecordEditInput {
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
|
||||
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
|
||||
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
|
||||
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface server-reported unmatched patches. The matched subset already
|
||||
* persisted, so this must NOT throw (a throw would roll back applied state) —
|
||||
* warn and emit save-failure telemetry with a distinct reason instead.
|
||||
*/
|
||||
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
|
||||
const unmatchedIds = batch.patches
|
||||
.filter((_, index) => matched[index] === false)
|
||||
.map(describeBatchPatchTarget);
|
||||
if (unmatchedIds.length === 0) return;
|
||||
console.warn(
|
||||
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
|
||||
`${batch.sourceFile} (their z-order will revert on reload):`,
|
||||
unmatchedIds.join(", "),
|
||||
);
|
||||
trackStudioSaveFailure({
|
||||
source: "dom_edit",
|
||||
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
|
||||
filePath: batch.sourceFile,
|
||||
mutationType: "z-reorder-unmatched",
|
||||
});
|
||||
}
|
||||
|
||||
async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
|
||||
const before = await readProjectFileContent(projectId, batch.sourceFile);
|
||||
const response = await fetch(
|
||||
@@ -77,8 +105,10 @@ async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
|
||||
}
|
||||
const result = (await response.json()) as {
|
||||
changed?: boolean;
|
||||
matched?: boolean[];
|
||||
content?: string;
|
||||
};
|
||||
if (Array.isArray(result.matched)) reportUnmatchedBatchPatches(batch, result.matched);
|
||||
return {
|
||||
sourceFile: batch.sourceFile,
|
||||
changed: result.changed === true,
|
||||
|
||||
@@ -36,6 +36,7 @@ type ReorderCommit = (
|
||||
key?: string;
|
||||
}>,
|
||||
coalesceKeyOverride?: string,
|
||||
actionKind?: string,
|
||||
) => Promise<void>;
|
||||
|
||||
function renderReorderHook(
|
||||
@@ -176,6 +177,150 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps distinct actions in distinct default coalesce keys", async () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "clip-a";
|
||||
document.body.appendChild(el);
|
||||
const captured: CapturedBatchCall[] = [];
|
||||
let commit: ReorderCommit | undefined;
|
||||
const root = renderReorderHook(captured, (fn) => (commit = fn));
|
||||
|
||||
await act(async () => {
|
||||
await commit!(
|
||||
[{ element: el, zIndex: 1, id: "clip-a", sourceFile: "index.html" }],
|
||||
undefined,
|
||||
"bring-forward",
|
||||
);
|
||||
await commit!(
|
||||
[{ element: el, zIndex: 0, id: "clip-a", sourceFile: "index.html" }],
|
||||
undefined,
|
||||
"send-backward",
|
||||
);
|
||||
});
|
||||
|
||||
// Same element set, different actions — the keys must differ so the two
|
||||
// edits never coalesce into one undo step within the coalesce window.
|
||||
expect(captured).toHaveLength(2);
|
||||
expect(captured[0]?.options.coalesceKey).toBe("z-reorder:bring-forward:clip-a");
|
||||
expect(captured[1]?.options.coalesceKey).toBe("z-reorder:send-backward:clip-a");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("updates the store zIndex synchronously for entries that carry a store key", async () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "clip-a";
|
||||
document.body.appendChild(el);
|
||||
usePlayerStore.getState().setElements([
|
||||
{
|
||||
id: "clip-a",
|
||||
key: "index.html#clip-a",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 1,
|
||||
track: 0,
|
||||
zIndex: 0,
|
||||
hasExplicitZIndex: false,
|
||||
},
|
||||
]);
|
||||
|
||||
let commit: ReorderCommit | undefined;
|
||||
let resolveBatch: (() => void) | undefined;
|
||||
function Harness() {
|
||||
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
|
||||
activeCompPath: "index.html",
|
||||
showToast: vi.fn(),
|
||||
writeProjectFile: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
editHistory: { recordEdit: vi.fn(async () => {}) },
|
||||
projectIdRef: { current: null },
|
||||
reloadPreview: vi.fn(),
|
||||
clearDomSelection: vi.fn(),
|
||||
// Persist stays pending so the assertion below can only be satisfied
|
||||
// by the SYNCHRONOUS store update (the lane-sync path's requirement).
|
||||
commitDomEditPatchBatches: () => new Promise((resolve) => (resolveBatch = resolve)),
|
||||
});
|
||||
commit = handleDomZIndexReorderCommit;
|
||||
return null;
|
||||
}
|
||||
const root = mountReactHarness(<Harness />);
|
||||
|
||||
let pending: Promise<void> | undefined;
|
||||
act(() => {
|
||||
pending = commit!([
|
||||
{
|
||||
element: el,
|
||||
zIndex: 5,
|
||||
id: "clip-a",
|
||||
sourceFile: "index.html",
|
||||
key: "index.html#clip-a",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().elements[0]).toMatchObject({
|
||||
zIndex: 5,
|
||||
hasExplicitZIndex: true,
|
||||
});
|
||||
|
||||
resolveBatch?.();
|
||||
await act(async () => pending);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// The canvas context-menu path: the menu no longer pre-applies styles, so the
|
||||
// hook sees the PRISTINE element — prior styles are captured before any
|
||||
// mutation and a failed persist restores them exactly (previously the menu's
|
||||
// optimistic write made the "rollback" restore the already-mutated values,
|
||||
// and the never-persisted position patch silently reverted on reload).
|
||||
it("rolls back a static, inline-style-free element to pristine styles on failure", async () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "clip-a";
|
||||
el.style.position = "static"; // happy-dom computes "" for unset position
|
||||
document.body.appendChild(el);
|
||||
const failure = new Error("persist failed");
|
||||
|
||||
let commit: ReorderCommit | undefined;
|
||||
function Harness() {
|
||||
const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
|
||||
activeCompPath: "index.html",
|
||||
showToast: vi.fn(),
|
||||
writeProjectFile: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
editHistory: { recordEdit: vi.fn(async () => {}) },
|
||||
projectIdRef: { current: null },
|
||||
reloadPreview: vi.fn(),
|
||||
clearDomSelection: vi.fn(),
|
||||
commitDomEditPatchBatches: vi.fn(async () => {
|
||||
// The live styles were applied by the hook before persist ran.
|
||||
expect(el.style.zIndex).toBe("2");
|
||||
expect(el.style.position).toBe("relative");
|
||||
throw failure;
|
||||
}),
|
||||
});
|
||||
commit = handleDomZIndexReorderCommit;
|
||||
return null;
|
||||
}
|
||||
const root = mountReactHarness(<Harness />);
|
||||
|
||||
let rejection: unknown;
|
||||
await act(async () => {
|
||||
try {
|
||||
await commit!(
|
||||
[{ element: el, zIndex: 2, id: "clip-a", sourceFile: "index.html" }],
|
||||
undefined,
|
||||
"bring-forward",
|
||||
);
|
||||
} catch (error) {
|
||||
rejection = error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(rejection).toBe(failure);
|
||||
expect(el.style.zIndex).toBe("");
|
||||
expect(el.style.position).toBe("static");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("rolls back only live and store state after an atomic reorder failure", async () => {
|
||||
const writeProjectFile = vi.fn(async () => {});
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
|
||||
@@ -146,6 +146,7 @@ export function useElementLifecycleOps({
|
||||
key?: string;
|
||||
}>,
|
||||
gestureCoalesceKey?: string,
|
||||
actionKind?: string,
|
||||
) => {
|
||||
if (entries.length === 0) return Promise.resolve();
|
||||
// Resolver shadow (telemetry-only, decoupled from cutover): record whether
|
||||
@@ -153,9 +154,13 @@ export function useElementLifecycleOps({
|
||||
onReorderShadow?.(
|
||||
entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
|
||||
);
|
||||
// The default key carries the action kind so two DIFFERENT actions on the
|
||||
// same element set (e.g. "bring-forward" then "send-backward" within the
|
||||
// coalesce window) never merge into one undo step. Callers that share a
|
||||
// gesture (lane moves) pass an explicit gestureCoalesceKey instead.
|
||||
const coalesceKey =
|
||||
gestureCoalesceKey ??
|
||||
`z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
||||
`z-reorder:${actionKind ?? "reorder"}:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
||||
const patchesBySourceFile = new Map<string, DomEditPatchBatch["patches"]>();
|
||||
const rollbacks: Array<() => void> = [];
|
||||
for (const entry of entries) {
|
||||
|
||||
Reference in New Issue
Block a user