Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/studio/src/components/editor/manualEditingAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,14 @@ export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
false,
);

// Resolver-parity tripwire (telemetry-only, decoupled from cutover).
// Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on
// divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false.
// Soak gate: retire once zero element_not_found divergences over a clean window.
export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED"],
true,
);

export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
10 changes: 7 additions & 3 deletions packages/studio/src/hooks/useDomEditSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { PatchTarget } from "../utils/sourcePatcher";
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
import type { Composition } from "@hyperframes/sdk";
import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
import { runResolverShadow } from "../utils/sdkResolverShadow";
import { useAskAgentModal } from "./useAskAgentModal";
import { useDomSelection } from "./useDomSelection";
import { usePreviewInteraction } from "./usePreviewInteraction";
Expand Down Expand Up @@ -239,8 +240,10 @@ export function useDomEditSession({
buildDomSelectionFromTarget,
forceReloadSdkSession,
onTrySdkPersist: sdkSession
? (selection, operations, originalContent, targetPath, options) =>
sdkCutoverPersist(
? (selection, operations, originalContent, targetPath, options) => {
// Resolver shadow runs regardless of the cutover flag — decoupled tripwire.
runResolverShadow(sdkSession, selection.hfId, operations);
return sdkCutoverPersist(
selection,
operations,
originalContent,
Expand All @@ -254,7 +257,8 @@ export function useDomEditSession({
compositionPath: activeCompPath,
},
options,
)
);
}
: undefined,
onTrySdkDelete: sdkSession
? (hfId, originalContent, targetPath) =>
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/utils/sdkCutover.gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from "vitest";
// turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
vi.mock("../components/editor/manualEditingAvailability", () => ({
STUDIO_SDK_CUTOVER_ENABLED: false,
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
}));
vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));

Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/utils/sdkCutover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { MutableRefObject } from "react";

vi.mock("../components/editor/manualEditingAvailability", () => ({
STUDIO_SDK_CUTOVER_ENABLED: true,
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
}));
vi.mock("./studioTelemetry", () => ({
trackStudioEvent: vi.fn(),
Expand Down
108 changes: 61 additions & 47 deletions packages/studio/src/utils/sdkCutover.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { MutableRefObject } from "react";
import type { Composition, EditOp, GsapTweenSpec } from "@hyperframes/sdk";
import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { EditHistoryKind } from "./editHistory";
import type { PatchOperation } from "./sourcePatcher";
import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";

const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
"inline-style",
Expand Down Expand Up @@ -34,10 +36,6 @@ const RESERVED_CUTOVER_ATTRS = new Set<string>([
"data-hold-fill",
]);

// The attribute name the SDK setAttribute op carries for this patch op (or null
// if the op isn't an attribute). Shared by patchOpsToSdkEditOps and the reserved
// gate so the name they reason about can't drift: a bare `attribute` op is
// force-prefixed `data-`; an `html-attribute` op keeps its raw name.
function sdkAttrName(op: PatchOperation): string | null {
if (op.type === "attribute") {
return op.property.startsWith("data-") ? op.property : `data-${op.property}`;
Expand All @@ -54,38 +52,6 @@ function mapsToReservedAttr(op: PatchOperation): boolean {
return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
}

/**
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
*
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
* style changes naturally). One SDK op is emitted per non-style op.
*/
function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
const result: EditOp[] = [];
const styles: Record<string, string | null> = {};
let hasStyles = false;

for (const op of ops) {
if (op.type === "inline-style") {
styles[op.property] = op.value;
hasStyles = true;
} else if (op.type === "text-content") {
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
} else if (op.type === "attribute" || op.type === "html-attribute") {
const name = sdkAttrName(op);
if (name !== null) {
result.push({ type: "setAttribute", target: hfId, name, value: op.value });
}
}
}

if (hasStyles) {
result.unshift({ type: "setStyle", target: hfId, styles });
}

return result;
}

export function shouldUseSdkCutover(
flagEnabled: boolean,
hasSession: boolean,
Expand Down Expand Up @@ -250,6 +216,9 @@ export async function sdkTimingPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
// Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
// the SDK can't resolve a target the server timing path is addressing.
recordResolverParity(sdkSession, hfId, "setTiming");
// Dark-launch gate: without this, timing cutover runs whenever an SDK session
// exists (it always does, for shadow/selection) — flipping the flag OFF would
// NOT disable it. Gate here so flag-off routes back to the legacy server path.
Expand Down Expand Up @@ -286,6 +255,18 @@ export function sdkGsapTweenPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
// Resolver tripwire — runs BEFORE this function's own cutover gate (decoupled).
// add targets an element (element-resolution parity); set/remove target an
// animationId (animation-resolution parity). Done here, not via
// dispatchGsapOpAndPersist's resolverTarget, because the gate below returns
// before that call when cutover is off.
if (op.kind === "add") recordResolverParity(sdkSession, op.target, "addGsapTween");
else
recordAnimationResolverParity(
sdkSession,
op.animationId,
op.kind === "set" ? "setGsapTween" : "removeGsapTween",
);
// Leading dark-launch gate so flag-off does no SDK touch (getElement) at all —
// matches the other three chokepoints' discipline.
if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false);
Expand Down Expand Up @@ -313,7 +294,13 @@ async function dispatchGsapOpAndPersist(
deps: CutoverDeps,
options: CutoverOptions | undefined,
dispatch: (s: Composition) => void,
resolverTarget?: { animationId: string; opLabel: string },
): Promise<boolean> {
// Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when
// the SDK can't resolve the animationId the server GSAP path is addressing.
if (resolverTarget) {
recordAnimationResolverParity(sdkSession, resolverTarget.animationId, resolverTarget.opLabel);
}
// Dark-launch gate (shared chokepoint for every GSAP-op cutover persist):
// flag OFF → return false → caller falls back to the legacy server path.
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
Expand Down Expand Up @@ -354,8 +341,13 @@ export function sdkGsapKeyframePersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })),
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })),
{ animationId, opLabel: "addGsapKeyframe" },
);
}

Expand All @@ -367,8 +359,13 @@ export function sdkGsapRemoveKeyframePersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
s.dispatch({ type: "removeGsapKeyframe", animationId, percentage }),
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.dispatch({ type: "removeGsapKeyframe", animationId, percentage }),
{ animationId, opLabel: "removeGsapKeyframe" },
);
}

Expand All @@ -381,8 +378,13 @@ export function sdkGsapRemovePropertyPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
s.dispatch({ type: "removeGsapProperty", animationId, property, from }),
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.dispatch({ type: "removeGsapProperty", animationId, property, from }),
{ animationId, opLabel: "removeGsapProperty" },
);
}

Expand All @@ -405,8 +407,13 @@ export function sdkGsapRemoveAllKeyframesPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
s.dispatch({ type: "removeAllKeyframes", animationId }),
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.dispatch({ type: "removeAllKeyframes", animationId }),
{ animationId, opLabel: "removeAllKeyframes" },
);
}

Expand All @@ -418,8 +425,13 @@ export function sdkGsapConvertToKeyframesPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
s.dispatch({ type: "convertToKeyframes", animationId, resolvedFromValues }),
return dispatchGsapOpAndPersist(
targetPath,
sdkSession,
deps,
options,
(s) => s.dispatch({ type: "convertToKeyframes", animationId, resolvedFromValues }),
{ animationId, opLabel: "convertToKeyframes" },
);
}

Expand All @@ -430,6 +442,8 @@ export async function sdkDeletePersist(
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
): Promise<boolean> {
// Resolver tripwire — runs BEFORE the cutover gate (decoupled).
recordResolverParity(sdkSession, hfId, "removeElement");
// Dark-launch gate: flag OFF → legacy server delete path.
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
Expand Down
43 changes: 43 additions & 0 deletions packages/studio/src/utils/sdkOpMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Studio PatchOperation[] → SDK EditOp[] mapping.
*
* Lives in its own module so both the cutover path (sdkCutover.ts) and the
* resolver-shadow tripwire (sdkResolverShadow.ts) can use it without a circular
* import between those two.
*
* Multiple inline-style ops are coalesced into a single setStyle (the SDK
* batches style changes naturally). One SDK op is emitted per non-style op.
*/

import type { EditOp } from "@hyperframes/sdk";
import type { PatchOperation } from "./sourcePatcher";

export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
const result: EditOp[] = [];
const styles: Record<string, string | null> = {};
let hasStyles = false;

for (const op of ops) {
if (op.type === "inline-style") {
styles[op.property] = op.value;
hasStyles = true;
} else if (op.type === "text-content") {
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
} else if (op.type === "attribute") {
result.push({
type: "setAttribute",
target: hfId,
name: op.property.startsWith("data-") ? op.property : `data-${op.property}`,
value: op.value,
});
} else if (op.type === "html-attribute") {
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
}
}

if (hasStyles) {
result.unshift({ type: "setStyle", target: hfId, styles });
}

return result;
}
Loading
Loading