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
2 changes: 1 addition & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export function StudioApp() {
domEditSaveTimestampRef,
setRefreshKey,
});
const sdkSession = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef);
useEffect(() => {
if (activeCompPathHydrated) return;
if (!fileManager.fileTreeLoaded) return;
Expand All @@ -175,7 +176,6 @@ export function StudioApp() {
reloadPreview: () => setRefreshKey((k) => k + 1),
pendingTimelineEditPathRef,
});
const sdkSession = useSdkSession(projectId, activeCompPath ?? "index.html");
const timelineEditing = useTimelineEditing({
projectId,
activeCompPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,13 @@ export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
true,
);

// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
// instead of the server patch-element API. Default false; enable via
// VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open.
export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
false,
);

export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
41 changes: 41 additions & 0 deletions packages/studio/src/hooks/useDomEditSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { shouldUseSdkCutover } from "../utils/sdkCutover";
import type { PatchOperation } from "../utils/sourcePatcher";

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);
});
});
54 changes: 34 additions & 20 deletions packages/studio/src/hooks/useSdkSession.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import type { MutableRefObject } from "react";
import { openComposition } from "@hyperframes/sdk";
import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
import type { Composition } from "@hyperframes/sdk";
Expand All @@ -20,33 +21,41 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
* (projectId, activeCompPath) change, disposes the old one on cleanup, and
* re-opens it when the active composition file changes on disk (code editor,
* agent, or server-side patch) so the in-memory linkedom document never goes
* stale.
* stale. The persist queue writes back to `activeCompPath` (not the
* "composition.html" default).
*
* Opened WITHOUT a persist queue: this session is shadow-telemetry +
* selection-sync only — it reads from the server but must NEVER write back.
* Shadow dispatch ops mutate the in-memory model and are discarded on the next
* reload-on-change (the studio's own authoritative write triggers it). Routing
* authoritative writes through this session (cutover, Step 3c+) must re-add
* persist TOGETHER WITH self-write suppression — without it, the SDK's
* serialize() output races and clobbers the studio's authoritative write.
* The session is idle until Step 3c routes dispatch ops through it; re-opening
* is therefore purely additive — no SDK self-write exists yet, so there is no
* persist echo. Step 3c must add self-write suppression once dispatch writes.
*/
// Time-window heuristic: suppress file-change reloads for 2 s after our own
// SDK cutover write, to avoid an echo-reload on the write we just committed.
// Footgun: if 2 s is too short (slow FS / network) the reload fires anyway;
// if too long it masks a legitimate external edit. The long-term shape is a
// sequence number or content hash threaded through the persist event so the
// comparison is exact rather than time-based.
const SELF_WRITE_SUPPRESS_MS = 2000;

export function useSdkSession(
projectId: string | null,
activeCompPath: string | null,
domEditSaveTimestampRef?: MutableRefObject<number>,
): Composition | null {
const [session, setSession] = useState<Composition | null>(null);
const [reloadToken, setReloadToken] = useState(0);

// ── Re-open on external change to the active composition ──
useEffect(() => {
if (!activeCompPath) return;
// Pre-existing clone of the file-change reload handler (usePreviewPersistence);
// surfaced by this PR's adjacent edits, not introduced by it.
// fallow-ignore-next-line code-duplication
const handler = (payload?: unknown) => {
if (shouldReloadSdkSession(payload, activeCompPath)) {
setReloadToken((t) => t + 1);
}
if (!shouldReloadSdkSession(payload, activeCompPath)) return;
// Suppress reload triggered by our own SDK cutover write.
if (
domEditSaveTimestampRef &&
Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS
)
return;
setReloadToken((t) => t + 1);
};
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
Expand All @@ -56,6 +65,7 @@ export function useSdkSession(
const es = new EventSource("/api/events");
es.addEventListener("file-change", handler);
return () => es.close();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCompPath]);

// ── Open / re-open the session ──
Expand All @@ -66,7 +76,7 @@ export function useSdkSession(
}

let cancelled = false;
let comp: Composition | null = null;
const compRef = { current: null as Composition | null };

const adapter = createHttpAdapter({
projectFilesUrl: `/api/projects/${projectId}`,
Expand All @@ -75,15 +85,19 @@ export function useSdkSession(
.read(activeCompPath)
.then(async (content) => {
if (cancelled || typeof content !== "string") return;
// No persist — shadow/selection only; see the hook docstring. The SDK
// must not write back to the server while it shadows the authoritative
// studio path.
comp = await openComposition(content);
const comp = await openComposition(content, {
persist: adapter,
persistPath: activeCompPath,
});
comp.on("persist:error", (e) => {
console.warn("[sdk] persist:error", e.error);
});
// Cleanup may have fired while openComposition was awaited; dispose immediately.
if (cancelled) {
comp.dispose();
return;
}
compRef.current = comp;
setSession(comp);
})
.catch(() => {
Expand All @@ -92,7 +106,7 @@ export function useSdkSession(

return () => {
cancelled = true;
const c = comp;
const c = compRef.current;
if (c) void c.flush().finally(() => c.dispose());
};
}, [projectId, activeCompPath, reloadToken]);
Expand Down
Loading
Loading