Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 14 additions & 13 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore } from "./player";
import { StudioOverlays } from "./components/StudioOverlays";
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
import { useCaptionStore } from "./captions/store";
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
Expand All @@ -22,6 +23,7 @@ import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
Expand Down Expand Up @@ -56,25 +58,19 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
import { DomEditProvider } from "./contexts/DomEditContext";
import { StudioSplash } from "./components/StudioSplash";
import { useServerConnection } from "./hooks/useServerConnection";
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
import {
normalizeStudioCompositionPath,
readStudioUrlStateFromWindow,
resolveMasterCompositionPath,
} from "./utils/studioUrlState";
import { trackStudioSessionStart } from "./telemetry/events";
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
// fallow-ignore-next-line complexity
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const viewModeValue = useViewModeState();
useEffect(() => {
if (resolving || waitingForServer) return;
if (hasFiredSessionStart()) return;
markSessionStartFired();
trackStudioSessionStart({ has_project: projectId != null });
}, [projectId, resolving, waitingForServer]);
useStudioSessionStart(projectId, resolving, waitingForServer);
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
() => initialUrlStateRef.current.activeCompPath == null,
Expand Down Expand Up @@ -144,20 +140,24 @@ export function StudioApp() {
setActiveCompPathHydrated(true);
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
const previewPersistence = usePreviewPersistence({
projectId,
showToast,
readOptionalProjectFile: fileManager.readOptionalProjectFile,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
previewIframeRef,
activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview: () => setRefreshKey((k) => k + 1),
});
const externalFileChanges = useStudioExternalFileChanges({
projectId,
activeCompPath,
masterCompPath,
fileManager,
previewPersistence,
pendingTimelineEditPathRef,
reloadPreview,
});
const invalidateGsapCacheRef = useRef<() => void>(() => {});
// Stable identity — what the ref indirection is for. An inline arrow re-created
// the memoized timeline handlers (it is in their deps) on every render.
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
const timelineEditing = useTimelineEditing({
projectId,
Expand Down Expand Up @@ -483,12 +483,13 @@ export function StudioApp() {
})();
}}
/>
{previewPersistence.domEditSaveQueuePaused && (
{previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
<SaveQueuePausedBanner
message={previewPersistence.domEditSaveQueuePaused}
onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
/>
)}
<ExternalFileConflictBanner coordinator={externalFileChanges} />
{viewModeValue.viewMode === "storyboard" && (
<StoryboardView
projectId={projectId}
Expand Down
17 changes: 17 additions & 0 deletions packages/studio/src/hooks/externalFileChangeOwnership.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

describe("external file-change subscription ownership", () => {
it("has one subscriber instead of independent Preview and SDK listeners", () => {
const preview = readFileSync(new URL("./usePreviewPersistence.ts", import.meta.url), "utf8");
const sdk = readFileSync(new URL("./useSdkSession.ts", import.meta.url), "utf8");
const coordinator = readFileSync(
new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
"utf8",
);

expect(preview).not.toContain('hot.on("hf:file-change"');
expect(sdk).not.toContain('hot.on("hf:file-change"');
expect(coordinator.match(/hot\.on\("hf:file-change"/g)).toHaveLength(1);
});
});
18 changes: 18 additions & 0 deletions packages/studio/src/hooks/externalFileReloadBus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { addExternalFileReloadListener, notifyExternalFileReload } from "./externalFileReloadBus";

describe("external file reload bus", () => {
it("lets the sole watcher reload every SDK owner of the changed path", () => {
const first = vi.fn();
const second = vi.fn();
const removeFirst = addExternalFileReloadListener(first);
const removeSecond = addExternalFileReloadListener(second);

notifyExternalFileReload("scenes/card.html");
expect(first).toHaveBeenCalledWith("scenes/card.html");
expect(second).toHaveBeenCalledWith("scenes/card.html");

removeFirst();
removeSecond();
});
});
12 changes: 12 additions & 0 deletions packages/studio/src/hooks/externalFileReloadBus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
type ExternalFileReloadListener = (path: string) => void;

const listeners = new Set<ExternalFileReloadListener>();

export function addExternalFileReloadListener(listener: ExternalFileReloadListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

export function notifyExternalFileReload(path: string): void {
for (const listener of listeners) listener(path);
Comment thread
miguel-heygen marked this conversation as resolved.
Outdated
}
23 changes: 23 additions & 0 deletions packages/studio/src/hooks/usePreviewPersistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
import { drainStudioSaveQueues } from "./usePreviewPersistence";

describe("drainStudioSaveQueues", () => {
it("does not erase a pending-field conflict with a clean DOM queue", async () => {
const conflict = new StudioFileConflictError({
filePath: "index.html",
currentVersion: "v2",
currentContent: "external",
attemptedContent: "studio",
});
const waitForDomQueue = vi.fn(async () => ({ status: "clean" as const }));

await expect(
drainStudioSaveQueues(
async () => ({ status: "conflict" as const, error: conflict }),
waitForDomQueue,
),
).resolves.toEqual({ status: "conflict", error: conflict });
expect(waitForDomQueue).not.toHaveBeenCalled();
});
});
80 changes: 22 additions & 58 deletions packages/studio/src/hooks/usePreviewPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { useMountEffect } from "./useMountEffect";
import {
installStudioManualEditSeekReapply,
reapplyPositionEditsAfterSeek,
readStudioFileChangePath,
} from "../components/editor/manualEdits";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import type { EditHistoryKind } from "../utils/editHistory";
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
import {
flushStudioPendingEdits,
type StudioPendingEditsDrainResult,
} from "../utils/studioPendingEdits";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
import { usePlayerStore } from "../player";
Expand All @@ -29,19 +31,12 @@ interface RecordEditInput {
}

interface UsePreviewPersistenceParams {
projectId: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
readOptionalProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (entry: RecordEditInput) => Promise<void>;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
activeCompPathRef: React.MutableRefObject<string | null>;
/** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits).
* Used to suppress file-change echoes so we don't reload after our own saves. */
domEditSaveTimestampRef: React.MutableRefObject<number>;
/** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
* events for these paths are always suppressed since the preview is already up-to-date. */
pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
/** Called to reload the preview after undo/redo or external file changes. */
reloadPreview: () => void;
}
Expand Down Expand Up @@ -71,19 +66,13 @@ function installManualEditReapply(iframe: HTMLIFrameElement): void {
}
}

function shouldReloadForStudioFileChange(
payload: unknown,
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>> | undefined,
domEditSaveTimestampRef: React.MutableRefObject<number>,
): boolean {
const changedPath = readStudioFileChangePath(payload);
if (!changedPath) return false;
const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
if (pendingTimelinePaths?.has(changedPath)) {
pendingTimelinePaths.delete(changedPath);
return false;
}
return Date.now() - domEditSaveTimestampRef.current >= 4000;
export async function drainStudioSaveQueues(
flushPendingFields: () => Promise<StudioPendingEditsDrainResult>,
waitForDomQueue: () => Promise<DomEditSaveDrainResult>,
): Promise<StudioPendingEditsDrainResult | DomEditSaveDrainResult> {
const pending = await flushPendingFields();
if (pending.status !== "clean") return pending;
return waitForDomQueue();
}

// fallow-ignore-next-line complexity
Expand All @@ -107,16 +96,13 @@ async function clearLegacyStudioMotionFile(
// ── Hook ──

export function usePreviewPersistence({
projectId,
showToast,
readOptionalProjectFile: _readOptionalProjectFile,
writeProjectFile: _writeProjectFile,
recordEdit: _recordEdit,
previewIframeRef,
activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview,
pendingTimelineEditPathRef,
}: UsePreviewPersistenceParams) {
void _recordEdit;

Expand Down Expand Up @@ -152,22 +138,23 @@ export function usePreviewPersistence({
});
}

// Keep a ref to the latest projectId so async save callbacks always read the
// current value, even when the callback was captured in a stale closure.
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;

// ── Queue / drain helpers ──

const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => {
return domEditSaveQueueRef.current?.enqueue(save) ?? save();
}, []);

const waitForPendingDomEditSaves = useCallback(async () => {
await flushStudioPendingEdits();
await domEditSaveQueueRef.current?.waitForIdle();
const drainPendingDomEditSaves = useCallback(async () => {
return drainStudioSaveQueues(flushStudioPendingEdits, async () => {
return (await domEditSaveQueueRef.current?.waitForIdle()) ?? { status: "clean" as const };
});
}, []);

const waitForPendingDomEditSaves = useCallback(async (): Promise<void> => {
const result = await drainPendingDomEditSaves();
if (result.status !== "clean") throw result.error;
}, [drainPendingDomEditSaves]);

const resetDomEditSaveQueueBreaker = useCallback(() => {
domEditSaveQueueRef.current?.reset();
setDomEditSaveQueuePaused(null);
Expand Down Expand Up @@ -235,35 +222,12 @@ export function usePreviewPersistence({
void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
});

// ── Listen for external file changes (HMR / SSE) ──
useMountEffect(() => {
const handler = (payload?: unknown) => {
if (
shouldReloadForStudioFileChange(
payload,
pendingTimelineEditPathRef,
domEditSaveTimestampRef,
)
) {
// fallow-ignore-next-line code-duplication
reloadPreview();
}
};
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
return () => import.meta.hot?.off?.("hf:file-change", handler);
}
// SSE fallback for embedded studio server
const es = new EventSource("/api/events");
es.addEventListener("file-change", handler);
return () => es.close();
});

return {
domTextCommitVersionRef,
domEditSaveQueueRef,
applyStudioManualEditsToPreviewRef,
queueDomEditSave,
drainPendingDomEditSaves,
waitForPendingDomEditSaves,
domEditSaveQueuePaused,
resetDomEditSaveQueueBreaker,
Expand Down
59 changes: 9 additions & 50 deletions packages/studio/src/hooks/useSdkSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readStudioFileChangePath } from "../components/editor/manualEdits";
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
import { trackStudioEvent } from "../utils/studioTelemetry";
import type { PublishSdkSession } from "../utils/sdkCutover";
import { addExternalFileReloadListener } from "./externalFileReloadBus";

/**
* Read a project file's content, or undefined on a non-2xx (optional read).
Expand Down Expand Up @@ -48,22 +49,6 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
* stale. The session has NO persist queue — Studio is the sole file writer; see
* the open effect below.
*/
// Reload-suppression baseline: a file-change within this window of our own SDK
// cutover write is a CANDIDATE echo, but the decision is content-identity based
// (isSelfWriteEcho) not time-only — so an undo write that lands inside the window
// still reloads (its reverted bytes were never registered as a self-write). The
// window only bounds how long a registered self-write stays suppressible.
const SELF_WRITE_SUPPRESS_MS = 2000;

/** Best-effort read of the changed file's content from a file-change payload. */
function readFileChangeContent(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
if (typeof record.content === "string") return record.content;
if ("data" in record) return readFileChangeContent(record.data);
return null;
}

/**
* Decide whether a file-change for the active composition should reload the SDK
* session. `content` is the new on-disk bytes (from the payload or a re-read);
Expand Down Expand Up @@ -159,6 +144,7 @@ export function useSdkSession(
activeCompPath: string | null,
domEditSaveTimestampRef?: MutableRefObject<number>,
): SdkSessionHandle {
void domEditSaveTimestampRef;
Comment thread
miguel-heygen marked this conversation as resolved.
Outdated
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
const sessionOwnersRef = useRef(new WeakMap<Composition, SdkSessionOwner>());
Expand All @@ -171,40 +157,13 @@ export function useSdkSession(
const reloadTokenRef = useRef(reloadToken);
reloadTokenRef.current = reloadToken;

// ── Re-open on external change to the active composition ──
useEffect(() => {
if (!activeCompPath) return;
const compPath = activeCompPath;
const readProjectId = projectId ?? null;
const handler = (payload?: unknown) => {
if (!shouldReloadSdkSession(payload, compPath)) return;
const withinWindow =
!!domEditSaveTimestampRef &&
Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
const decide = (content: string | null) => {
if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
};
const payloadContent = readFileChangeContent(payload);
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
// (an undo's reverted bytes won't match a registered self-write → reload).
if (payloadContent != null || readProjectId == null) {
decide(payloadContent);
return;
}
readProjectFileOptional(readProjectId, compPath)
.then((c) => decide(c ?? null))
.catch(() => decide(null));
};
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
return () => import.meta.hot?.off?.("hf:file-change", handler);
}
// SSE fallback for the embedded studio server.
const es = new EventSource("/api/events");
es.addEventListener("file-change", handler);
return () => es.close();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCompPath, projectId]);
useEffect(
() =>
addExternalFileReloadListener((changedPath) => {
if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
}),
[],
);

// ── Open / re-open the session ──
useEffect(() => {
Expand Down
Loading
Loading