chore: rescue snapshot of dnits-zorder before devbox cleanup

This commit is contained in:
Miguel Ángel
2026-10-03 05:21:47 -04:00
parent 027461b64f
commit 0998f4fca2
10 changed files with 107 additions and 142 deletions
@@ -108,11 +108,11 @@ export function resolveZIndexEntries(
}
export interface DomEditZOrder {
/** False in a read-only preview, for an element no longer in the preview, or at that end of its stacking set. */
/** False in a read-only preview, for an element no longer in the live preview, or at that end of its stacking set. */
enabled: (sel: DomEditSelection, action: ZOrderAction) => boolean;
/** Resolve and commit one step; false when nothing is sent to be saved. */
apply: (sel: DomEditSelection, action: ZOrderAction) => boolean;
/** Commit patches already resolved (the canvas menu resolves its own); false when none could be saved. */
/** Commit patches already resolved (the canvas menu resolves its own); false when none could be sent to be saved. */
commit: (
sel: DomEditSelection,
patches: ReadonlyArray<ZOrderPatch>,
@@ -121,6 +121,10 @@ export interface DomEditZOrder {
) => boolean;
}
/** A selection from before a preview reload holds an element of a document no window shows, with stale z values. */
const isLive = (element: HTMLElement) =>
element.isConnected && element.ownerDocument.defaultView !== null;
// The canvas menu's z-order (write, undo, lane mirror) for any caller in DomEditProvider.
export function useDomEditZOrder(): DomEditZOrder {
const { handleDomZIndexReorderCommit } = useDomEditActionsContext();
@@ -129,8 +133,7 @@ export function useDomEditZOrder(): DomEditZOrder {
const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
const enabled = useCallback<DomEditZOrder["enabled"]>(
(sel, action) =>
!readOnly && sel.element.isConnected && isZOrderActionEnabled(sel.element, action),
(sel, action) => !readOnly && isLive(sel.element) && isZOrderActionEnabled(sel.element, action),
[readOnly],
);
@@ -171,8 +174,7 @@ export function useDomEditZOrder(): DomEditZOrder {
const apply = useCallback<DomEditZOrder["apply"]>(
(sel, action) => {
// A selection from before a preview reload holds a detached element and stale z values.
if (!sel.element.isConnected) return false;
if (!isLive(sel.element)) return false;
const step = resolveZOrderStep(sel.element, action);
return step !== null && commit(sel, step.patches, action, step.crossed);
},
+3 -1
View File
@@ -150,7 +150,9 @@ describe("DOM editing package exports", () => {
expect(zOrder?.apply(sel, "bring-to-front")).toBe(false);
expect(zOrder?.commit(sel, [{ element: back, zIndex: 1 }], "bring-to-front", null)).toBe(false);
await mount(false);
parent.remove();
// A reload leaves the old nodes connected to their old document, one no window shows.
document.implementation.createHTMLDocument().body.append(parent);
expect(back.isConnected).toBe(true);
expect(zOrder?.enabled(sel, "bring-to-front"), "a selection from before a reload").toBe(false);
expect(zOrder?.apply(sel, "bring-to-front")).toBe(false);
expect(commitZ).not.toHaveBeenCalled();
@@ -91,8 +91,7 @@ export type DomEditCommitDeclineReason =
| "styles-not-editable"
| "not-text-editable"
| "preview-stale"
| "persist-failed"
| "read-only";
| "persist-failed";
export type DomEditCommitOutcome =
| { ok: true; persistence?: DomEditPersistOutcome }
@@ -19,7 +19,6 @@ export function makeLifecycleOpsParams(
projectIdRef: { current: null },
reloadPreview: vi.fn(),
clearDomSelection: vi.fn(),
readOnlyPreview: false,
...overrides,
};
}
@@ -42,6 +42,7 @@ interface RenderDomEditCommitsOptions {
onTrySdkPersist?: () => Promise<CutoverResult>;
queueDomEditSave?: <T>(save: () => Promise<T>) => Promise<T>;
projectIdRef?: MutableRefObject<string | null>;
readOnlyPreview?: boolean;
}
type FetchHandler = (
@@ -230,7 +231,7 @@ function renderDomEditCommits(
refreshDomEditSelectionFromPreview: vi.fn(),
buildDomSelectionFromTarget: vi.fn(async () => null),
onTrySdkPersist: options.onTrySdkPersist,
readOnlyPreview: false,
readOnlyPreview: options.readOnlyPreview ?? false,
});
return null;
}
@@ -264,97 +265,100 @@ describe("useDomEditCommits z-index reorder persistence", () => {
document.body.replaceChildren();
});
it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
const original =
'<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
const after =
'<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
const fetchMock = vi.fn(
async (
input: Parameters<typeof fetch>[0],
_init?: Parameters<typeof fetch>[1],
): Promise<Response> => {
const url = requestUrl(input);
if (url.endsWith("/file-mutations/patch-element-batches")) {
return jsonResponse({
durable: true,
files: [
{
sourceFile: "index.html",
changed: true,
matched: [true, true, true],
before: original,
after,
},
],
});
}
throw new Error(`Unexpected fetch: ${url}`);
},
);
vi.stubGlobal("fetch", fetchMock);
const { iframe, element } = createPreviewElement(
'<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
);
element.id = "a";
const elements = [
element,
iframe.contentDocument!.getElementById("b")!,
iframe.contentDocument!.getElementById("c")!,
];
const rendered = renderDomEditCommits(createSelection(element), iframe);
it.each([false, true])(
"persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload (read-only preview %s: the timeline's z sync stays editable)",
async (readOnlyPreview) => {
const original =
'<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
const after =
'<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
const fetchMock = vi.fn(
async (
input: Parameters<typeof fetch>[0],
_init?: Parameters<typeof fetch>[1],
): Promise<Response> => {
const url = requestUrl(input);
if (url.endsWith("/file-mutations/patch-element-batches")) {
return jsonResponse({
durable: true,
files: [
{
sourceFile: "index.html",
changed: true,
matched: [true, true, true],
before: original,
after,
},
],
});
}
throw new Error(`Unexpected fetch: ${url}`);
},
);
vi.stubGlobal("fetch", fetchMock);
const { iframe, element } = createPreviewElement(
'<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
);
element.id = "a";
const elements = [
element,
iframe.contentDocument!.getElementById("b")!,
iframe.contentDocument!.getElementById("c")!,
];
const rendered = renderDomEditCommits(createSelection(element), iframe, { readOnlyPreview });
try {
await act(async () => {
await rendered.hook.handleDomZIndexReorderCommit(
elements.map((item, index) => ({
element: item,
zIndex: 3 - index,
id: item.id,
sourceFile: "index.html",
})),
"z-reorder:test",
try {
await act(async () => {
await rendered.hook.handleDomZIndexReorderCommit(
elements.map((item, index) => ({
element: item,
zIndex: 3 - index,
id: item.id,
sourceFile: "index.html",
})),
"z-reorder:test",
);
});
const batchPosts = fetchMock.mock.calls.filter(([input]) =>
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
);
});
const batchPosts = fetchMock.mock.calls.filter(([input]) =>
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
);
const singlePosts = fetchMock.mock.calls.filter(([input]) =>
requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
);
expect(batchPosts).toHaveLength(1);
expect(singlePosts).toHaveLength(0);
expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
batches: [
{
sourceFile: "index.html",
patches: expect.arrayContaining([
expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
]),
},
],
});
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
expect(rendered.recordEdit).toHaveBeenCalledWith({
label: "Reorder layers",
coalesceKey: "z-reorder:test",
// Unbounded per-gesture fold window (keys are unique per gesture):
// the z entry and its mirror/lane counterpart fold across the server
// round-trip that separates them.
coalesceMs: Number.POSITIVE_INFINITY,
files: { "index.html": { before: original, after } },
});
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
// The live DOM + store already hold the final state and the server matched
// every style-only patch, so the reload is provably redundant.
expect(rendered.reloadPreview).not.toHaveBeenCalled();
} finally {
rendered.cleanup();
}
});
const singlePosts = fetchMock.mock.calls.filter(([input]) =>
requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
);
expect(batchPosts).toHaveLength(1);
expect(singlePosts).toHaveLength(0);
expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
batches: [
{
sourceFile: "index.html",
patches: expect.arrayContaining([
expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
]),
},
],
});
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
expect(rendered.recordEdit).toHaveBeenCalledWith({
label: "Reorder layers",
coalesceKey: "z-reorder:test",
// Unbounded per-gesture fold window (keys are unique per gesture):
// the z entry and its mirror/lane counterpart fold across the server
// round-trip that separates them.
coalesceMs: Number.POSITIVE_INFINITY,
files: { "index.html": { before: original, after } },
});
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
// The live DOM + store already hold the final state and the server matched
// every style-only patch, so the reload is provably redundant.
expect(rendered.reloadPreview).not.toHaveBeenCalled();
} finally {
rendered.cleanup();
}
},
);
it("falls back to reloading when the server response omits matched[]", async () => {
// Without a matched[] confirmation the persist can't be proven in sync with
@@ -295,7 +295,6 @@ export function useDomEditCommits({
onReorderShadow,
forceReloadSdkSession,
commitDomEditPatchBatches,
readOnlyPreview,
});
return {
@@ -105,16 +105,6 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
expect(requests.some((url) => url.includes("remove-elements"))).toBe(false);
});
it("deletes nothing in a read-only preview and says why", async () => {
const ops = mountDeleteOps({ projectIdRef: { current: "p1" }, readOnlyPreview: true });
let outcome: unknown;
await act(async () => {
outcome = await ops.handleDomEditElementsDelete([selectionFor("a")]);
});
expect(outcome).toEqual({ ok: false, reason: "read-only" });
expect(requests).toEqual([]);
});
it("reports missing project and selection without starting a request", async () => {
const projectIdRef = { current: null as string | null };
const ops = mountDeleteOps({ projectIdRef });
@@ -121,28 +121,6 @@ describe("useElementLifecycleOps — z-index reorder payload", () => {
act(() => root.unmount());
});
it("a read-only preview reorders nothing and reports it unsaved, so the lane mirror is skipped", async () => {
const el = document.body.appendChild(document.createElement("div"));
el.id = "card";
const batches = vi.fn(async () => ({ durable: true, allMatched: true, changed: true }));
let commit: ReorderCommit | undefined;
function Harness() {
commit = useElementLifecycleOps(
makeLifecycleOpsParams({ commitDomEditPatchBatches: batches, readOnlyPreview: true }),
).handleDomZIndexReorderCommit;
return null;
}
const root = mountReactHarness(<Harness />);
const result = await commit!([
{ element: el, zIndex: 3, id: "card", sourceFile: "index.html" },
]);
expect(result).toEqual({ durable: false, allMatched: false, changed: false });
expect(batches).not.toHaveBeenCalled();
expect(el.style.zIndex).toBe("");
act(() => root.unmount());
el.remove();
});
it("requests skipReload on every z-reorder persist (live DOM already final)", async () => {
// The commit applies the z-index (and any injected position) to the live
// iframe DOM and the store synchronously, so the persisted style-only patch
@@ -37,7 +37,6 @@ interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
/** Resync the SDK session after a server-fallback delete. */
forceReloadSdkSession?: () => void;
commitDomEditPatchBatches: CommitDomEditPatchBatches;
readOnlyPreview: boolean;
/** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */
onElementDeleted?: (selection: DomEditSelection) => void;
}
@@ -84,14 +83,12 @@ export function useElementLifecycleOps({
onReorderShadow,
forceReloadSdkSession,
commitDomEditPatchBatches,
readOnlyPreview,
onElementDeleted,
}: UseElementLifecycleOpsParams) {
// fallow-ignore-next-line complexity
const handleDomEditElementsDelete = useCallback(
// fallow-ignore-next-line complexity
async (selections: DomEditSelection[]): Promise<DomEditCommitOutcome> => {
if (readOnlyPreview) return domEditCommitDeclined("read-only");
const pid = projectIdRef.current;
if (!pid) return domEditCommitDeclined("no-project");
const [selection] = selections;
@@ -224,7 +221,6 @@ export function useElementLifecycleOps({
onElementDeleted,
forceReloadSdkSession,
projectIdRef,
readOnlyPreview,
reloadPreview,
showToast,
writeProjectFile,
@@ -250,9 +246,6 @@ export function useElementLifecycleOps({
actionKind?: string,
) => {
if (entries.length === 0) return Promise.resolve();
// Refused before any write, so the lane mirror after it is skipped too.
if (readOnlyPreview)
return Promise.resolve({ durable: false, allMatched: false, changed: false });
// One async owner must bracket reveal tokens, optimistic DOM/store state,
// atomic persistence, rollback, and the final persistence-count release.
// Splitting those phases would make transaction ownership less explicit.
@@ -391,7 +384,7 @@ export function useElementLifecycleOps({
}
})();
},
[commitDomEditPatchBatches, onReorderShadow, readOnlyPreview],
[commitDomEditPatchBatches, onReorderShadow],
);
return {
@@ -248,7 +248,6 @@ function renderTimelineEditingHookWithLifecycle(input: {
projectIdRef: { current: "p1" },
reloadPreview: vi.fn(),
clearDomSelection: vi.fn(),
readOnlyPreview: false,
commitDomEditPatchBatches: input.commitDomEditPatchBatches,
});
const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit);