fix(studio): extend resolver-shadow runtime-node filter to timing/GSAP/reorder chokepoints (#1839)

* fix(studio): source-filter recordResolverParity (async + sourceHfIdCount)

* refactor(studio): extract cutover-eligibility checks to unblock filesize cap

* fix(studio): pass on-disk source to recordResolverParity at timing/gsap/delete chokepoints

* fix(studio): void the reorderElements recordResolverParity call (now async)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(studio): resolve max-review findings (telemetry race, await, reorder filter, count invariant)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-01 14:37:48 -07:00
committed by GitHub
co-authored by Claude Sonnet 5
parent e5ec4cf532
commit c215a20c8e
7 changed files with 509 additions and 168 deletions
@@ -0,0 +1,277 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { shouldUseSdkCutover } from "../utils/sdkCutover";
import type { PatchOperation } from "../utils/sourcePatcher";
import type { Composition } from "@hyperframes/sdk";
import type { UseDomEditSessionParams } from "./useDomEditSession";
const styleOp = (property: string, value: string): PatchOperation => ({
type: "inline-style",
property,
value,
});
const attrOp = (property: string, value: string): PatchOperation => ({
type: "attribute",
property,
value,
});
describe("shouldUseSdkCutover", () => {
it("returns false when flag is disabled", () => {
expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
});
it("returns false when no SDK session", () => {
expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false);
});
it("returns false when selection has no hfId", () => {
expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false);
expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false);
});
it("returns false when ops array is empty", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false);
});
it("returns true when all conditions met with supported op types", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true);
expect(
shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]),
).toBe(true);
});
});
// ── onReorderShadow source filter (Fix 3) ────────────────────────────────────
//
// useDomEditSession composes ~9 sub-hooks; the only one relevant to this fix is
// useDomEditCommits, which receives the onReorderShadow callback built inline.
// Every other sub-hook is stubbed to a minimal shape so the hook under test can
// render without pulling in unrelated preview/GSAP/selection machinery.
const recordResolverParity = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined } = {
fn: undefined,
};
vi.mock("../utils/sdkResolverShadow", () => ({
runResolverShadow: vi.fn(),
recordResolverParity: (...args: unknown[]) => recordResolverParity(...args),
}));
vi.mock("./useDomEditCommits", () => ({
useDomEditCommits: (params: { onReorderShadow?: (targets: string[]) => void }) => {
capturedOnReorderShadow.fn = params.onReorderShadow;
return {
resolveImportedFontAsset: vi.fn(),
handleDomStyleCommit: vi.fn(),
handleDomAttributeCommit: vi.fn(),
handleDomAttributeLiveCommit: vi.fn(),
handleDomHtmlAttributeCommit: vi.fn(),
handleDomTextCommit: vi.fn(),
handleDomTextFieldStyleCommit: vi.fn(),
handleDomAddTextField: vi.fn(),
handleDomRemoveTextField: vi.fn(),
handleDomBoxSizeCommit: vi.fn(),
handleDomManualEditsReset: vi.fn(),
handleDomEditElementDelete: vi.fn(),
handleDomZIndexReorderCommit: vi.fn(),
};
},
}));
vi.mock("./useDomSelection", () => ({
useDomSelection: () => ({
domEditSelection: null,
domEditGroupSelections: [],
domEditHoverSelection: null,
activeGroupElement: null,
domEditSelectionRef: { current: null },
domEditGroupSelectionsRef: { current: [] },
setActiveGroupElement: vi.fn(),
applyDomSelection: vi.fn(),
clearDomSelection: vi.fn(),
buildDomSelectionFromTarget: vi.fn(),
resolveDomSelectionFromPreviewPoint: vi.fn(),
resolveAllDomSelectionsFromPreviewPoint: vi.fn(),
updateDomEditHoverSelection: vi.fn(),
buildDomSelectionForTimelineElement: vi.fn(),
handleTimelineElementSelect: vi.fn(),
refreshDomEditSelectionFromPreview: vi.fn(),
applyMarqueeSelection: vi.fn(),
}),
}));
vi.mock("./useAskAgentModal", () => ({
useAskAgentModal: () => ({
agentModalOpen: false,
agentModalAnchorPoint: null,
copiedAgentPrompt: null,
agentPromptSelectionContext: null,
setAgentModalOpen: vi.fn(),
setAgentPromptSelectionContext: vi.fn(),
setAgentModalAnchorPoint: vi.fn(),
handleAskAgent: vi.fn(),
handleAgentModalSubmit: vi.fn(),
}),
}));
vi.mock("./useStudioSelectionPublisher", () => ({
useStudioSelectionPublisher: () => {},
}));
vi.mock("./useGsapTweenCache", () => ({
useGsapCacheVersion: () => ({ version: 0, bump: vi.fn() }),
}));
vi.mock("./useGsapScriptCommits", () => ({
useGsapScriptCommits: () => ({
commitMutation: vi.fn(),
updateGsapProperty: vi.fn(),
updateGsapMeta: vi.fn(),
deleteGsapAnimation: vi.fn(),
deleteAllForSelector: vi.fn(),
addGsapAnimation: vi.fn(),
addGsapProperty: vi.fn(),
removeGsapProperty: vi.fn(),
updateGsapFromProperty: vi.fn(),
addGsapFromProperty: vi.fn(),
removeGsapFromProperty: vi.fn(),
addKeyframe: vi.fn(),
addKeyframeBatch: vi.fn(),
removeKeyframe: vi.fn(),
moveKeyframe: vi.fn(),
resizeKeyframedTween: vi.fn(),
convertToKeyframes: vi.fn(),
removeAllKeyframes: vi.fn(),
setArcPath: vi.fn(),
updateArcSegment: vi.fn(),
}),
}));
vi.mock("./useGroupCommits", () => ({
useGroupCommits: () => ({
groupSelection: vi.fn(),
ungroupSelection: vi.fn(),
}),
}));
vi.mock("./useDomEditWiring", () => ({
useDomEditWiring: () => ({
onClickToSource: vi.fn(),
selectedGsapAnimations: [],
gsapMultipleTimelines: false,
gsapUnsupportedTimelinePattern: false,
trackGsapInteractionFailure: vi.fn(),
makeFetchFallback: vi.fn(),
handleGsapUpdateProperty: vi.fn(),
handleGsapUpdateMeta: vi.fn(),
handleGsapDeleteAnimation: vi.fn(),
handleGsapDeleteAllForElement: vi.fn(),
handleGsapAddAnimation: vi.fn(),
handleGsapAddProperty: vi.fn(),
handleGsapRemoveProperty: vi.fn(),
handleGsapUpdateFromProperty: vi.fn(),
handleGsapAddFromProperty: vi.fn(),
handleGsapRemoveFromProperty: vi.fn(),
handleGsapAddKeyframe: vi.fn(),
handleGsapAddKeyframeBatch: vi.fn(),
handleGsapRemoveKeyframe: vi.fn(),
handleGsapMoveKeyframeToPlayhead: vi.fn(),
handleGsapMoveKeyframe: vi.fn(),
handleGsapResizeKeyframedTween: vi.fn(),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveAllKeyframes: vi.fn(),
handleResetSelectedElementKeyframes: vi.fn(),
}),
}));
vi.mock("./usePreviewInteraction", () => ({
usePreviewInteraction: () => ({
handlePreviewCanvasMouseDown: vi.fn(),
handlePreviewCanvasPointerMove: vi.fn(),
handlePreviewCanvasPointerLeave: vi.fn(),
handleBlockedDomMove: vi.fn(),
handleDomManualDragStart: vi.fn(),
}),
}));
vi.mock("./useGsapAwareEditing", () => ({
useGsapAwareEditing: () => ({
handleGsapAwarePathOffsetCommit: vi.fn(),
handleGsapAwareGroupPathOffsetCommit: vi.fn(),
handleGsapAwareBoxSizeCommit: vi.fn(),
handleGsapAwareRotationCommit: vi.fn(),
commitAnimatedProperty: vi.fn(),
commitAnimatedProperties: vi.fn(),
handleSetArcPath: vi.fn(),
handleUpdateArcSegment: vi.fn(),
handleUnroll: vi.fn(),
commitMutation: vi.fn(),
}),
}));
// Tell React this is an act-capable environment so act(...) flushes effects.
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("onReorderShadow source filter", () => {
it("passes a readSource function as the 4th recordResolverParity argument when sdkSession and activeCompPath are set", async () => {
const { useDomEditSession } = await import("./useDomEditSession");
const readProjectFile = vi.fn(async (path: string) => `content of ${path}`);
// Minimal opaque test double: no sub-hook under test actually calls into the
// session (useDomEditCommits and recordResolverParity are both mocked above),
// so it only needs to flow through as a reference.
const sdkSession = {} as unknown as Composition;
function Probe() {
const params: UseDomEditSessionParams = {
projectId: "proj-1",
activeCompPath: "index.html",
isMasterView: false,
compIdToSrc: new Map(),
captionEditMode: false,
compositionLoading: false,
previewIframeRef: { current: null },
timelineElements: [],
setSelectedTimelineElementId: vi.fn(),
setRightCollapsed: vi.fn(),
setRightPanelTab: vi.fn(),
showToast: vi.fn(),
refreshPreviewDocumentVersion: vi.fn(),
queueDomEditSave: vi.fn(async (save: () => Promise<void>) => save()),
readProjectFile,
writeProjectFile: vi.fn(async () => {}),
updateEditingFileContent: vi.fn(),
domEditSaveTimestampRef: { current: 0 },
editHistory: { recordEdit: vi.fn(async () => {}) },
fileTree: [],
importedFontAssetsRef: { current: [] },
projectDir: null,
projectIdRef: { current: "proj-1" },
previewIframe: null,
refreshKey: 0,
previewDocumentVersion: 0,
rightPanelTab: "design",
applyStudioManualEditsToPreviewRef: { current: async () => {} },
syncPreviewHistoryHotkey: vi.fn(),
reloadPreview: vi.fn(),
setRefreshKey: vi.fn(),
sdkSession,
forceReloadSdkSession: vi.fn(),
};
useDomEditSession(params);
return null;
}
const container = document.createElement("div");
const root = createRoot(container);
act(() => {
root.render(<Probe />);
});
try {
expect(capturedOnReorderShadow.fn).toBeTypeOf("function");
capturedOnReorderShadow.fn?.(["hf-target"]);
expect(recordResolverParity).toHaveBeenCalledWith(
sdkSession,
"hf-target",
"reorderElements",
expect.any(Function),
);
} finally {
act(() => root.unmount());
}
});
});