feat(studio): stage 7 step 3c — sdk cutover for inline-style ops (#1522)

Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set,
inline-style PatchOps are routed through the SDK session's in-memory document
model instead of the server patch-element API. The SDK serialize() result is
written back through the same writeProjectFile + editHistory.recordEdit path,
so the on-disk output is identical to the legacy route.

- packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() +
  shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on
  each write to suppress the echo file-change reload.
- packages/studio/src/components/editor/manualEditingAvailability.ts: adds
  STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes
  STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available.
- packages/studio/src/hooks/useSdkSession.ts: adds optional
  domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS)
  gates file-change reloads so SDK writes don't echo back as external edits.
- packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession
  so the suppress window can gate reloads triggered by SDK cutover writes.
- Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts
  (new, 50 lines) — guard function + happy-path assertions.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:10:07 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent e662fcdea2
commit ab7145ad9e
6 changed files with 511 additions and 21 deletions
+34 -20
View File
@@ -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";
@@ -20,19 +21,25 @@ 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);
@@ -40,13 +47,15 @@ export function useSdkSession(
// ── 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);
@@ -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 ──
@@ -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}`,
@@ -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(() => {
@@ -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]);