mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
e662fcdea2
commit
ab7145ad9e
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user