fix(studio): a resize leaves a box to GSAP when GSAP tweens anything the CSS box writer sets

gsapWritesBox now reads its channels from BOX_SIZE_STYLE_PROPS, the writer's own
property set, instead of width and height alone. A host-level test pins the
GSAP-free resize route through useDomGeometryCommit.
This commit is contained in:
Miguel Angel Simon Sierra
2026-09-30 23:57:00 -07:00
parent 3e729494a5
commit e07cc394dd
4 changed files with 36 additions and 6 deletions
@@ -118,7 +118,7 @@ export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperatio
/* ── Box size patches ────────────────────────────────────────────── */
const BOX_SIZE_STYLE_PROPS = [
export const BOX_SIZE_STYLE_PROPS = [
"width",
"height",
"min-width",
@@ -686,7 +686,10 @@ describe("a resize's anchor member", () => {
expect(member.element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
});
it("leaves a box whose size GSAP tweens to the GSAP writer", () => {
expect(anchorMember({ width: 300 }).plainTranslate).toBe(false);
});
it.each(["width", "maxWidth", "minHeight", "flexBasis", "scale"])(
"leaves a box whose %s GSAP tweens, which the CSS box writer also sets, to the GSAP writer",
(channel) => {
expect(anchorMember({ [channel]: 300 }).plainTranslate).toBe(false);
},
);
});
@@ -11,6 +11,7 @@
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
import { roundTo3 } from "../utils/rounding";
import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
/**
* A GSAP tween's `vars` object — intentionally open: it mixes channel values
@@ -446,9 +447,13 @@ export function gsapWritesPosition(el: Element): boolean {
return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS);
}
/** GSAP owns this element's box: its position, or its width or height. Else a resize writes CSS. */
const BOX_CHANNELS = BOX_SIZE_STYLE_PROPS.map((prop) =>
prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()),
);
/** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */
export function gsapWritesBox(el: Element): boolean {
return gsapWritesPosition(el) || gsapWritesChannels(el, ["width", "height"]);
return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS);
}
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
@@ -176,6 +176,28 @@ describe("useDomGeometryCommit, from the package entry", () => {
unmount();
});
it("saves a GSAP-free resize as its inline size, with no GSAP script and no animation read", async () => {
const calls = stubPatchServer();
const { element, recordEdit, hook, unmount } = renderHost();
await expect(
hook().commitBoxSize(makeSelection("card", element), { width: 300, height: 90 }),
).resolves.toEqual({ ok: true });
expect(element.style.getPropertyValue("width")).toBe("300px");
expect(calls.patches).toEqual([
expect.objectContaining({
operations: expect.arrayContaining([
{ type: "inline-style", property: "width", value: "300px" },
{ type: "inline-style", property: "height", value: "90px" },
]),
}),
]);
expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
expect(recordEdit).toHaveBeenCalledTimes(1);
unmount();
});
it("puts a GSAP-free element's translate back when its move cannot be saved", async () => {
stubPatchServer(500);
const { element, hook, unmount } = renderHost();