mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 12:08:50 +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:
@@ -3,7 +3,11 @@ import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
|
||||
import { resolveZIndexEntries } from "../nle/PreviewOverlays";
|
||||
import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
|
||||
import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
|
||||
import { CanvasContextMenu } from "./CanvasContextMenu";
|
||||
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
|
||||
installReactActEnvironment();
|
||||
@@ -24,7 +28,7 @@ afterEach(() => {
|
||||
|
||||
function renderMenu(props: {
|
||||
selection: DomEditSelection;
|
||||
onApplyZIndex?: () => void;
|
||||
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
|
||||
onDelete?: (selection: DomEditSelection) => void;
|
||||
}) {
|
||||
root = createRoot(host);
|
||||
@@ -113,3 +117,110 @@ describe("CanvasContextMenu — handler gating", () => {
|
||||
expect(document.body.querySelector(".border-t")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ──
|
||||
|
||||
function pressMenuItem(label: string) {
|
||||
const button = zOrderButtons().find((b) => b.textContent === label);
|
||||
expect(button).toBeDefined();
|
||||
act(() => {
|
||||
button!.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */
|
||||
function makeStaticFamily() {
|
||||
const parent = document.createElement("div");
|
||||
const target = document.createElement("div");
|
||||
target.id = "target";
|
||||
// In happy-dom an unset computed position is "" (not "static"), which would
|
||||
// skip the commit hook's static-position injection; declare it explicitly so
|
||||
// the test exercises the browser default.
|
||||
target.style.position = "static";
|
||||
const other = document.createElement("div");
|
||||
other.id = "other";
|
||||
parent.append(target, other);
|
||||
document.body.append(parent);
|
||||
return { parent, target, other };
|
||||
}
|
||||
|
||||
interface CapturedBatchCall {
|
||||
batches: DomEditPatchBatch[];
|
||||
options: { label: string; coalesceKey: string };
|
||||
}
|
||||
|
||||
/** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */
|
||||
function renderCommitHook(captured: CapturedBatchCall[]) {
|
||||
type Commit = ReturnType<typeof useElementLifecycleOps>["handleDomZIndexReorderCommit"];
|
||||
let commit: Commit | undefined;
|
||||
function Harness() {
|
||||
({ handleDomZIndexReorderCommit: commit } = 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: async (batches, options) => {
|
||||
captured.push({ batches, options });
|
||||
},
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
const hookHost = document.createElement("div");
|
||||
document.body.append(hookHost);
|
||||
const hookRoot = createRoot(hookHost);
|
||||
act(() => hookRoot.render(<Harness />));
|
||||
return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
|
||||
}
|
||||
|
||||
describe("CanvasContextMenu — z-action commit path", () => {
|
||||
it("never mutates live styles itself and persists the position patch for a static element", async () => {
|
||||
const { target } = makeStaticFamily();
|
||||
const selection = makeSelection("Target", target);
|
||||
const captured: CapturedBatchCall[] = [];
|
||||
const { commit, cleanup } = renderCommitHook(captured);
|
||||
|
||||
// Wire onApplyZIndex the way the app does (PreviewOverlays → the commit
|
||||
// hook), asserting the menu has NOT touched the DOM when it fires — the
|
||||
// hook must capture true pre-change styles for its rollback.
|
||||
const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
|
||||
renderMenu({
|
||||
selection,
|
||||
onApplyZIndex: (patches, action) => {
|
||||
stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
|
||||
const { entries } = resolveZIndexEntries(selection, patches);
|
||||
void commit(entries, undefined, action);
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => pressMenuItem("Bring forward"));
|
||||
|
||||
// The menu left the element pristine; only the commit hook wrote styles.
|
||||
expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]);
|
||||
expect(target.style.zIndex).toBe("1");
|
||||
expect(target.style.position).toBe("relative");
|
||||
|
||||
// The persisted payload carries BOTH the z-index and the injected position,
|
||||
// so the reorder survives the post-commit reloadPreview().
|
||||
expect(captured).toHaveLength(1);
|
||||
const targetPatch = captured[0]?.batches
|
||||
.flatMap((batch) => batch.patches)
|
||||
.find((patch) => patch.target.id === "target");
|
||||
expect(targetPatch?.operations).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: "inline-style", property: "z-index", value: "1" },
|
||||
{ type: "inline-style", property: "position", value: "relative" },
|
||||
]),
|
||||
);
|
||||
// F7: the action kind is part of the default undo coalesce key, so two
|
||||
// different menu actions never merge into one undo step.
|
||||
expect(captured[0]?.options.coalesceKey).toContain("bring-forward");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
* useContextMenuDismiss.
|
||||
*
|
||||
* ── Wiring (z-order persistence) ─────────────────────────────────────────────
|
||||
* Z-index changes are applied optimistically to the live iframe element(s) via
|
||||
* Z-index changes are resolved against the live iframe DOM via
|
||||
* `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
|
||||
* moving a target past an equal-z sibling can require renumbering the affected
|
||||
* set). The patches are surfaced through the `onApplyZIndex` prop.
|
||||
* set). The patches are surfaced through the `onApplyZIndex` prop; the menu
|
||||
* itself never mutates element styles — handleDomZIndexReorderCommit applies
|
||||
* the live z-index (and injects position when needed) in the same synchronous
|
||||
* flow, and captures the TRUE prior styles for its failure rollback.
|
||||
*
|
||||
* The prop MUST be wired at the call site to route through the full persist
|
||||
* path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
|
||||
@@ -27,6 +30,7 @@ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
||||
import {
|
||||
isZOrderActionEnabled,
|
||||
resolveZOrderChange,
|
||||
type ZOrderAction,
|
||||
type ZOrderPatch,
|
||||
} from "./canvasContextMenuZOrder";
|
||||
|
||||
@@ -38,12 +42,15 @@ interface CanvasContextMenuProps {
|
||||
selection: DomEditSelection;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Called with the resolved z-order patch list after an optimistic DOM update.
|
||||
* Each patch is an { element, zIndex } pair (the target and, when a renumber
|
||||
* is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
|
||||
* module-level wiring comment).
|
||||
* Called with the resolved z-order patch list and the menu action that
|
||||
* produced it (the action feeds the undo coalesce key, so two DIFFERENT
|
||||
* actions never merge into one undo step). Each patch is an
|
||||
* { element, zIndex } pair (the target and, when a renumber is needed,
|
||||
* affected siblings). The menu does NOT touch the live DOM — wire to
|
||||
* handleDomZIndexReorderCommit, which applies the live styles itself
|
||||
* (see module-level wiring comment).
|
||||
*/
|
||||
onApplyZIndex?: (patches: ZOrderPatch[]) => void;
|
||||
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
|
||||
/**
|
||||
* Delete the selected element. Wire to handleDomEditElementDelete from
|
||||
* useDomEditActionsContext — same path as the Delete/Backspace hotkey.
|
||||
@@ -93,20 +100,15 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
||||
const el = selection.element;
|
||||
|
||||
function handleZAction(action: ZAction) {
|
||||
// No persist handler → do NOT touch the live iframe DOM. An optimistic
|
||||
// write with nothing to persist just reverts on the next reload.
|
||||
if (!onApplyZIndex) return;
|
||||
const patches = resolveZOrderChange(el, action);
|
||||
if (patches === null) return;
|
||||
// Optimistic update — visible immediately even before persist completes.
|
||||
for (const patch of patches) {
|
||||
patch.element.style.zIndex = String(patch.zIndex);
|
||||
const view = patch.element.ownerDocument?.defaultView;
|
||||
if (view && view.getComputedStyle(patch.element).position === "static") {
|
||||
patch.element.style.position = "relative";
|
||||
}
|
||||
}
|
||||
onApplyZIndex(patches);
|
||||
// Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
|
||||
// live z-index (and injects position:relative for static elements) in the
|
||||
// same synchronous flow, so feedback is still instant — and it must read
|
||||
// the PRE-change styles itself, both to capture true rollback values and
|
||||
// to detect a static position that needs persisting.
|
||||
onApplyZIndex(patches, action);
|
||||
onClose();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
||||
import { CanvasContextMenu } from "./CanvasContextMenu";
|
||||
import type { ZOrderPatch } from "./canvasContextMenuZOrder";
|
||||
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
||||
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
||||
|
||||
// Re-exports for external consumers — preserving existing import paths.
|
||||
@@ -91,12 +91,17 @@ interface DomEditOverlayProps {
|
||||
*/
|
||||
onDeleteSelection?: (selection: DomEditSelection) => void;
|
||||
/**
|
||||
* Called with the resolved z-order patch list after an optimistic DOM update.
|
||||
* The patch list is tie-aware and may include sibling elements (see
|
||||
* canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from
|
||||
* Called with the resolved z-order patch list and the menu action that
|
||||
* produced it (feeds the undo coalesce key). The patch list is tie-aware and
|
||||
* may include sibling elements (see canvasContextMenuZOrder); the live DOM is
|
||||
* NOT yet mutated. Wire to handleDomZIndexReorderCommit from
|
||||
* useDomEditActionsContext. See CanvasContextMenu.tsx module comment.
|
||||
*/
|
||||
onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void;
|
||||
onApplyZIndex?: (
|
||||
selection: DomEditSelection,
|
||||
patches: ZOrderPatch[],
|
||||
action: ZOrderAction,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -562,8 +567,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
||||
}
|
||||
onApplyZIndex={
|
||||
onApplyZIndex
|
||||
? (patches) => {
|
||||
onApplyZIndex(contextMenu.sel, patches);
|
||||
? (patches, action) => {
|
||||
onApplyZIndex(contextMenu.sel, patches, action);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Layers } from "../../icons/SystemIcons";
|
||||
import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
|
||||
import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering";
|
||||
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
||||
|
||||
const TAG_ICONS: Record<string, string> = {
|
||||
video: "Vi",
|
||||
@@ -280,9 +281,17 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
selector: layer.selector,
|
||||
selectorIndex: layer.selectorIndex,
|
||||
sourceFile: layer.sourceFile,
|
||||
key: deriveTimelineStoreKey({
|
||||
domId: layer.id,
|
||||
selector: layer.selector,
|
||||
selectorIndex: layer.selectorIndex,
|
||||
sourceFile: layer.sourceFile,
|
||||
}),
|
||||
}));
|
||||
|
||||
handleDomZIndexReorderCommit(entries);
|
||||
// "layer-drag" keeps consecutive drops of the same sibling set coalescing
|
||||
// into one undo step, without merging with a context-menu z action.
|
||||
handleDomZIndexReorderCommit(entries, undefined, "layer-drag");
|
||||
},
|
||||
[handleDomZIndexReorderCommit],
|
||||
);
|
||||
|
||||
@@ -375,6 +375,27 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
|
||||
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
|
||||
});
|
||||
|
||||
it("ignores <template>/<noscript> siblings in the family", () => {
|
||||
// A renumber fallback once wrote z-index/position into <template> source
|
||||
// markup because templates entered the sibling family. They never paint —
|
||||
// exclude them like audio/script/style.
|
||||
const parent = document.createElement("div");
|
||||
const a = makeEl("a", "0");
|
||||
const template = document.createElement("template");
|
||||
template.style.zIndex = "2";
|
||||
const noscript = document.createElement("noscript");
|
||||
const target = makeEl("target", "0");
|
||||
parent.append(a, template, noscript, target);
|
||||
|
||||
const patches = resolveZOrderPatches(target, "send-to-back");
|
||||
for (const p of patches) {
|
||||
expect(p.element).not.toBe(template);
|
||||
expect(p.element).not.toBe(noscript);
|
||||
}
|
||||
const order = renderOrderIds(parent, { a, target }, patches);
|
||||
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
|
||||
});
|
||||
|
||||
it("a lone painting element beside only non-painting siblings has no family → null", () => {
|
||||
const parent = document.createElement("div");
|
||||
const target = makeEl("target", "1");
|
||||
|
||||
@@ -81,8 +81,18 @@ function isElementNode(node: Node): node is HTMLElement {
|
||||
* z-index onto the qa-clean audio element, and counting it as a sibling skews the
|
||||
* renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
|
||||
* non-painting and could otherwise pad the family / eat a z slot.
|
||||
* `<template>/<noscript>` never paint either — letting them in meant renumber
|
||||
* fallbacks wrote z-index/position into template source markup.
|
||||
*/
|
||||
const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
|
||||
const NON_PAINTING_TAGS = new Set([
|
||||
"AUDIO",
|
||||
"SCRIPT",
|
||||
"STYLE",
|
||||
"LINK",
|
||||
"META",
|
||||
"TEMPLATE",
|
||||
"NOSCRIPT",
|
||||
]);
|
||||
|
||||
/** A painting element: an element node whose tag actually renders pixels. */
|
||||
function isPaintingElement(node: Node): node is HTMLElement {
|
||||
@@ -112,7 +122,7 @@ function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex:
|
||||
return { entries, targetIndex };
|
||||
}
|
||||
|
||||
/** True if two DOM bounding rects intersect (even if touching). */
|
||||
/** True if two DOM bounding rects strictly overlap (rects that merely touch do NOT intersect). */
|
||||
function rectsIntersect(
|
||||
a: { left: number; top: number; right: number; bottom: number },
|
||||
b: { left: number; top: number; right: number; bottom: number },
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
import { readHfId, type DomEditSelection } from "../editor/domEditing";
|
||||
import { buildStableSelector } from "../editor/domEditingDom";
|
||||
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
||||
import type { BlockPreviewInfo } from "../sidebar/BlocksTab";
|
||||
import type { GestureRecordingState } from "../editor/GestureRecordControl";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -38,6 +39,8 @@ type ZIndexReorderEntry = {
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile: string;
|
||||
/** Timeline store key — lets the commit update the store zIndex synchronously. */
|
||||
key?: string;
|
||||
};
|
||||
|
||||
/** Can this element be robustly re-targeted for a persisted z change? */
|
||||
@@ -58,6 +61,12 @@ function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReord
|
||||
selector: sel.selector,
|
||||
selectorIndex: sel.selectorIndex,
|
||||
sourceFile: sel.sourceFile,
|
||||
key: deriveTimelineStoreKey({
|
||||
domId: sel.id ?? undefined,
|
||||
selector: sel.selector,
|
||||
selectorIndex: sel.selectorIndex,
|
||||
sourceFile: sel.sourceFile,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,7 +84,15 @@ function siblingZIndexEntry(
|
||||
const id = element.id || undefined;
|
||||
const selector = buildStableSelector(element);
|
||||
if (!canTargetZIndexElement(element, id, selector)) return null;
|
||||
return { element, zIndex, id, selector, selectorIndex: undefined, sourceFile };
|
||||
return {
|
||||
element,
|
||||
zIndex,
|
||||
id,
|
||||
selector,
|
||||
selectorIndex: undefined,
|
||||
sourceFile,
|
||||
key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Short human-readable label for a dropped sibling, for the console warning below. */
|
||||
@@ -88,14 +105,16 @@ function describeZIndexElement(element: HTMLElement): string {
|
||||
}
|
||||
|
||||
// Resolve z-index patches into commit entries; a sibling with no stable
|
||||
// id/selector can't be written to source, so it is collected as a label for the
|
||||
// revert-on-reload warning instead.
|
||||
function resolveZIndexEntries(
|
||||
// id/selector can't be written to source, so it is returned as `dropped` for
|
||||
// the revert-on-reload warning (and a live-only style write, so the resolved
|
||||
// stacking order still renders coherently). Exported so tests can drive the
|
||||
// menu → commit path through the same wiring the app uses.
|
||||
export function resolveZIndexEntries(
|
||||
sel: DomEditSelection,
|
||||
patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
|
||||
): { entries: ZIndexReorderEntry[]; droppedLabels: string[] } {
|
||||
): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
|
||||
const entries: ZIndexReorderEntry[] = [];
|
||||
const droppedLabels: string[] = [];
|
||||
const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
|
||||
for (const patch of patches) {
|
||||
if (patch.element === sel.element) {
|
||||
entries.push(selectedZIndexEntry(sel, patch.zIndex));
|
||||
@@ -103,9 +122,9 @@ function resolveZIndexEntries(
|
||||
}
|
||||
const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
|
||||
if (entry) entries.push(entry);
|
||||
else droppedLabels.push(describeZIndexElement(patch.element));
|
||||
else dropped.push(patch);
|
||||
}
|
||||
return { entries, droppedLabels };
|
||||
return { entries, dropped };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -205,19 +224,20 @@ export function PreviewOverlays({
|
||||
onRotationCommit={handleDomRotationCommit}
|
||||
onStyleCommit={handleDomStyleCommit}
|
||||
onDeleteSelection={handleDomEditElementDelete}
|
||||
onApplyZIndex={(sel, patches) => {
|
||||
const { entries, droppedLabels } = resolveZIndexEntries(sel, patches);
|
||||
if (droppedLabels.length > 0) {
|
||||
// The optimistic z-index has already been applied live at this point —
|
||||
// these siblings just won't be written to source, so they'll revert to
|
||||
// their prior stacking order on the next reload with no other signal.
|
||||
onApplyZIndex={(sel, patches, action) => {
|
||||
const { entries, dropped } = resolveZIndexEntries(sel, patches);
|
||||
if (dropped.length > 0) {
|
||||
// These siblings can't be written to source. Apply their live z
|
||||
// anyway so the resolved stacking order renders coherently — it
|
||||
// just reverts to the prior order on the next reload.
|
||||
for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
|
||||
console.warn(
|
||||
"[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
|
||||
"(will revert on reload):",
|
||||
droppedLabels.join(", "),
|
||||
dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
|
||||
);
|
||||
}
|
||||
if (entries.length > 0) handleDomZIndexReorderCommit(entries);
|
||||
if (entries.length > 0) handleDomZIndexReorderCommit(entries, undefined, action);
|
||||
}}
|
||||
gridVisible={snapPrefs.gridVisible}
|
||||
gridSpacing={snapPrefs.gridSpacing}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -57,7 +57,17 @@ describe("useTimelineStackingSync", () => {
|
||||
});
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ element: node, zIndex: 8, sourceFile: "nested.html" })],
|
||||
[
|
||||
expect.objectContaining({
|
||||
element: node,
|
||||
zIndex: 8,
|
||||
sourceFile: "nested.html",
|
||||
// The patch's store key rides along so the commit updates the store
|
||||
// zIndex synchronously on the lane-sync path (and rolls it back on
|
||||
// failure) instead of waiting for a preview reload.
|
||||
key: "a",
|
||||
}),
|
||||
],
|
||||
"clip-lane-move:7",
|
||||
);
|
||||
act(() => root.unmount());
|
||||
|
||||
@@ -74,6 +74,9 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
|
||||
selector: el.selector,
|
||||
selectorIndex: el.selectorIndex,
|
||||
sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
|
||||
// The store key: lets the commit update the store's zIndex
|
||||
// synchronously (and roll it back on failure).
|
||||
key: p.key,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -298,6 +298,25 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
|
||||
return element.key ?? element.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline store key for a z-reorder entry built OUTSIDE the timeline
|
||||
* expansion (canvas context menu / LayersPanel), so the reorder commit can
|
||||
* update the store's zIndex synchronously. Matches buildTimelineElementKey's
|
||||
* stable branches (`sourceFile#domId`, else the selector-based key). Undefined
|
||||
* when the element has neither a DOM id nor a selector — the timeline's
|
||||
* fallback branch needs its own fallbackIndex, which these callers don't have,
|
||||
* so such an entry simply skips the synchronous store update.
|
||||
*/
|
||||
export function deriveTimelineStoreKey(params: {
|
||||
domId?: string;
|
||||
selector?: string;
|
||||
selectorIndex?: number;
|
||||
sourceFile?: string;
|
||||
}): string | undefined {
|
||||
if (!params.domId && !params.selector) return undefined;
|
||||
return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM node querying
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user