fix(studio): reconcile external edits before reload (#2993)

* fix(studio): reconcile external edits before reload

* fix(ci): retry transient workspace installs

Make external reload retry behavior honest and isolate reload listeners.

Remove the dead SDK timestamp parameter.
This commit is contained in:
Miguel Ángel
2026-08-04 23:13:19 +00:00
committed by GitHub
parent 8a0dccfc72
commit ebdd1893c4
17 changed files with 374 additions and 148 deletions
+14 -14
View File
@@ -129,7 +129,7 @@ jobs:
- run: corepack enable - run: corepack enable
- run: corepack prepare pnpm@10.17.1 --activate - run: corepack prepare pnpm@10.17.1 --activate
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run build - run: bun run build
- run: bun run verify:packed-manifests - run: bun run verify:packed-manifests
@@ -148,7 +148,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run lint - run: bun run lint
# `fallow audit` runs dead-code + complexity + duplication analysis scoped to # `fallow audit` runs dead-code + complexity + duplication analysis scoped to
@@ -182,7 +182,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
- name: Run fallow audit - name: Run fallow audit
id: audit id: audit
# `bun install` above made `bunx fallow` resolve from node_modules, so # `bun install` above made `bunx fallow` resolve from node_modules, so
@@ -238,7 +238,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run format:check - run: bun run format:check
typecheck: typecheck:
@@ -256,7 +256,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run build - run: bun run build
- run: bun run --filter '*' typecheck - run: bun run --filter '*' typecheck
@@ -283,7 +283,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run test:scripts - run: bun run test:scripts
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
- run: bun run --cwd packages/core build - run: bun run --cwd packages/core build
@@ -314,7 +314,7 @@ jobs:
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ffmpeg sudo apt-get install -y --no-install-recommends ffmpeg
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
- run: bun run --cwd packages/core build - run: bun run --cwd packages/core build
- run: bun run --filter @hyperframes/engine build - run: bun run --filter @hyperframes/engine build
@@ -422,7 +422,7 @@ jobs:
node-version: 22 node-version: 22
- name: Install dependencies - name: Install dependencies
if: runner.os != 'Windows' if: runner.os != 'Windows'
run: bun install --frozen-lockfile --ignore-scripts run: bash scripts/ci/install-workspace-dependencies.sh --ignore-scripts
- name: Install dependencies - name: Install dependencies
if: runner.os == 'Windows' if: runner.os == 'Windows'
run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted
@@ -437,7 +437,7 @@ jobs:
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
# Build workspace deps so the sdk's @hyperframes/parsers + core subpath # Build workspace deps so the sdk's @hyperframes/parsers + core subpath
# imports resolve via the "node" export condition (dist) under vitest. # imports resolve via the "node" export condition (dist) under vitest.
- run: bun run --filter '@hyperframes/parsers' build - run: bun run --filter '@hyperframes/parsers' build
@@ -459,7 +459,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
# Runtime coverage now imports core modules that consume workspace # Runtime coverage now imports core modules that consume workspace
# subpaths. Build their dist exports before Vitest resolves them. # subpaths. Build their dist exports before Vitest resolves them.
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
@@ -480,7 +480,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
# Build workspace deps so the studio vite.config.ts (loaded by Node) can # Build workspace deps so the studio vite.config.ts (loaded by Node) can
# resolve @hyperframes/core and @hyperframes/studio-server via the "node" # resolve @hyperframes/core and @hyperframes/studio-server via the "node"
# export condition (dist). # export condition (dist).
@@ -523,7 +523,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
# Same reason as studio-load-smoke: vite.config.ts is loaded by Node and # Same reason as studio-load-smoke: vite.config.ts is loaded by Node and
# resolves the workspace packages through their "node" export condition. # resolves the workspace packages through their "node" export condition.
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
@@ -634,7 +634,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile - run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run build - run: bun run build
# Pack the CLI as a tarball (simulates what `npm publish` produces) # Pack the CLI as a tarball (simulates what `npm publish` produces)
@@ -711,7 +711,7 @@ jobs:
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
- uses: ./.github/actions/prepare-ffmpeg-bin - uses: ./.github/actions/prepare-ffmpeg-bin
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bash scripts/ci/install-workspace-dependencies.sh
- name: Build monorepo - name: Build monorepo
run: bun run build run: bun run build
+1 -1
View File
@@ -48,7 +48,7 @@
"player:perf": "bun run --filter @hyperframes/player perf", "player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .", "format:check": "oxfmt --check .",
"knip": "knip", "knip": "knip",
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs", "test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
"test:skills": "node --test 'skills/**/*.test.mjs'", "test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts", "generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts", "generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
+14 -14
View File
@@ -4,6 +4,7 @@ import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore } from "./player"; import { usePlayerStore } from "./player";
import { StudioOverlays } from "./components/StudioOverlays"; import { StudioOverlays } from "./components/StudioOverlays";
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner"; import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
import { useCaptionStore } from "./captions/store"; import { useCaptionStore } from "./captions/store";
import { useCaptionSync } from "./captions/hooks/useCaptionSync"; import { useCaptionSync } from "./captions/hooks/useCaptionSync";
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory"; import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
@@ -22,6 +23,7 @@ import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession"; import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync"; import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions"; import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard"; import { useClipboard } from "./hooks/useClipboard";
@@ -56,25 +58,19 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
import { DomEditProvider } from "./contexts/DomEditContext"; import { DomEditProvider } from "./contexts/DomEditContext";
import { StudioSplash } from "./components/StudioSplash"; import { StudioSplash } from "./components/StudioSplash";
import { useServerConnection } from "./hooks/useServerConnection"; import { useServerConnection } from "./hooks/useServerConnection";
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead"; import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
import { import {
normalizeStudioCompositionPath, normalizeStudioCompositionPath,
readStudioUrlStateFromWindow, readStudioUrlStateFromWindow,
resolveMasterCompositionPath, resolveMasterCompositionPath,
} from "./utils/studioUrlState"; } from "./utils/studioUrlState";
import { trackStudioSessionStart } from "./telemetry/events";
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
export function StudioApp() { export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection(); const { projectId, resolving, waitingForServer } = useServerConnection();
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow()); const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const viewModeValue = useViewModeState(); const viewModeValue = useViewModeState();
useEffect(() => { useStudioSessionStart(projectId, resolving, waitingForServer);
if (resolving || waitingForServer) return;
if (hasFiredSessionStart()) return;
markSessionStartFired();
trackStudioSessionStart({ has_project: projectId != null });
}, [projectId, resolving, waitingForServer]);
const [activeCompPath, setActiveCompPath] = useState<string | null>(null); const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState( const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
() => initialUrlStateRef.current.activeCompPath == null, () => initialUrlStateRef.current.activeCompPath == null,
@@ -130,7 +126,6 @@ export function StudioApp() {
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions( const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
projectId, projectId,
activeCompPath, activeCompPath,
domEditSaveTimestampRef,
masterCompPath, masterCompPath,
); );
useEffect(() => { useEffect(() => {
@@ -144,20 +139,24 @@ export function StudioApp() {
setActiveCompPathHydrated(true); setActiveCompPathHydrated(true);
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]); }, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
const previewPersistence = usePreviewPersistence({ const previewPersistence = usePreviewPersistence({
projectId,
showToast, showToast,
readOptionalProjectFile: fileManager.readOptionalProjectFile, readOptionalProjectFile: fileManager.readOptionalProjectFile,
writeProjectFile: fileManager.writeProjectFile, writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit, recordEdit: editHistory.recordEdit,
previewIframeRef, previewIframeRef,
activeCompPathRef, activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview: () => setRefreshKey((k) => k + 1), reloadPreview: () => setRefreshKey((k) => k + 1),
});
const externalFileChanges = useStudioExternalFileChanges({
projectId,
activeCompPath,
masterCompPath,
fileManager,
previewPersistence,
pendingTimelineEditPathRef, pendingTimelineEditPathRef,
reloadPreview,
}); });
const invalidateGsapCacheRef = useRef<() => void>(() => {}); 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 invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
const timelineEditing = useTimelineEditing({ const timelineEditing = useTimelineEditing({
projectId, projectId,
@@ -483,12 +482,13 @@ export function StudioApp() {
})(); })();
}} }}
/> />
{previewPersistence.domEditSaveQueuePaused && ( {previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
<SaveQueuePausedBanner <SaveQueuePausedBanner
message={previewPersistence.domEditSaveQueuePaused} message={previewPersistence.domEditSaveQueuePaused}
onRetry={previewPersistence.resetDomEditSaveQueueBreaker} onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
/> />
)} )}
<ExternalFileConflictBanner coordinator={externalFileChanges} />
{viewModeValue.viewMode === "storyboard" && ( {viewModeValue.viewMode === "storyboard" && (
<StoryboardView <StoryboardView
projectId={projectId} projectId={projectId}
@@ -52,7 +52,7 @@ export function DesignPanelPromoteProvider({
children: ReactNode; children: ReactNode;
}) { }) {
const targetPath = selection?.sourceFile || activeCompPath || "index.html"; const targetPath = selection?.sourceFile || activeCompPath || "index.html";
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef); const handle = useSdkSession(projectId, targetPath);
const rawPersist = useVariablesPersist({ const rawPersist = useVariablesPersist({
...persistDeps, ...persistDeps,
sdkSession: handle.session, sdkSession: handle.session,
@@ -113,4 +113,30 @@ describe("ExternalFileConflictBanner", () => {
await act(async () => root.unmount()); await act(async () => root.unmount());
}); });
it("does not offer retry when a failed DOM edit has no recoverable source candidate", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const coordinator: ExternalFileChangeCoordinatorHandle = {
blocked: {
status: "failed",
generation: 1,
path: "index.html",
error: new Error("offline"),
payload: { path: "index.html", version: "v2", content: "external" },
studioContent: null,
recovered: false,
},
retry: vi.fn(async () => undefined),
useExternalFile: vi.fn(async () => undefined),
keepStudioFile: vi.fn(async () => undefined),
};
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
expect(document.body.textContent).not.toContain("Retry save");
expect(document.body.textContent).toContain("Discard Studio edits and reload file");
await act(async () => root.unmount());
});
}); });
@@ -193,7 +193,7 @@ export function ExternalFileConflictBanner({
Review or export Studio draft Review or export Studio draft
</button> </button>
)} )}
{failure && !failure.recovered && ( {failure && !failure.recovered && failure.studioContent != null && (
<button type="button" onClick={() => void coordinator.retry()} className="underline"> <button type="button" onClick={() => void coordinator.retry()} className="underline">
Retry save Retry save
</button> </button>
@@ -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);
});
});
@@ -0,0 +1,34 @@
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();
});
it("isolates a broken listener so later SDK owners still reload", () => {
const broken = vi.fn(() => {
throw new Error("stale owner");
});
const healthy = vi.fn();
const removeBroken = addExternalFileReloadListener(broken);
const removeHealthy = addExternalFileReloadListener(healthy);
expect(() => notifyExternalFileReload("scenes/card.html")).not.toThrow();
expect(broken).toHaveBeenCalledWith("scenes/card.html");
expect(healthy).toHaveBeenCalledWith("scenes/card.html");
removeBroken();
removeHealthy();
});
});
@@ -0,0 +1,18 @@
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) {
try {
listener(path);
} catch {
// A stale SDK owner must not prevent sibling owners from reloading.
}
}
}
@@ -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();
});
});
@@ -3,12 +3,14 @@ import { useMountEffect } from "./useMountEffect";
import { import {
installStudioManualEditSeekReapply, installStudioManualEditSeekReapply,
reapplyPositionEditsAfterSeek, reapplyPositionEditsAfterSeek,
readStudioFileChangePath,
} from "../components/editor/manualEdits"; } from "../components/editor/manualEdits";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import type { EditHistoryKind } from "../utils/editHistory"; import type { EditHistoryKind } from "../utils/editHistory";
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue"; import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
import { flushStudioPendingEdits } from "../utils/studioPendingEdits"; import {
flushStudioPendingEdits,
type StudioPendingEditsDrainResult,
} from "../utils/studioPendingEdits";
import { trackStudioEvent } from "../utils/studioTelemetry"; import { trackStudioEvent } from "../utils/studioTelemetry";
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore"; import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
import { usePlayerStore } from "../player"; import { usePlayerStore } from "../player";
@@ -29,19 +31,12 @@ interface RecordEditInput {
} }
interface UsePreviewPersistenceParams { interface UsePreviewPersistenceParams {
projectId: string | null;
showToast: (message: string, tone?: "error" | "info") => void; showToast: (message: string, tone?: "error" | "info") => void;
readOptionalProjectFile: (path: string) => Promise<string>; readOptionalProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>; writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (entry: RecordEditInput) => Promise<void>; recordEdit: (entry: RecordEditInput) => Promise<void>;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>; previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
activeCompPathRef: React.MutableRefObject<string | 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. */ /** Called to reload the preview after undo/redo or external file changes. */
reloadPreview: () => void; reloadPreview: () => void;
} }
@@ -71,19 +66,13 @@ function installManualEditReapply(iframe: HTMLIFrameElement): void {
} }
} }
function shouldReloadForStudioFileChange( export async function drainStudioSaveQueues(
payload: unknown, flushPendingFields: () => Promise<StudioPendingEditsDrainResult>,
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>> | undefined, waitForDomQueue: () => Promise<DomEditSaveDrainResult>,
domEditSaveTimestampRef: React.MutableRefObject<number>, ): Promise<StudioPendingEditsDrainResult | DomEditSaveDrainResult> {
): boolean { const pending = await flushPendingFields();
const changedPath = readStudioFileChangePath(payload); if (pending.status !== "clean") return pending;
if (!changedPath) return false; return waitForDomQueue();
const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
if (pendingTimelinePaths?.has(changedPath)) {
pendingTimelinePaths.delete(changedPath);
return false;
}
return Date.now() - domEditSaveTimestampRef.current >= 4000;
} }
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
@@ -107,16 +96,13 @@ async function clearLegacyStudioMotionFile(
// ── Hook ── // ── Hook ──
export function usePreviewPersistence({ export function usePreviewPersistence({
projectId,
showToast, showToast,
readOptionalProjectFile: _readOptionalProjectFile, readOptionalProjectFile: _readOptionalProjectFile,
writeProjectFile: _writeProjectFile, writeProjectFile: _writeProjectFile,
recordEdit: _recordEdit, recordEdit: _recordEdit,
previewIframeRef, previewIframeRef,
activeCompPathRef, activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview, reloadPreview,
pendingTimelineEditPathRef,
}: UsePreviewPersistenceParams) { }: UsePreviewPersistenceParams) {
void _recordEdit; void _recordEdit;
@@ -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 ── // ── Queue / drain helpers ──
const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => { const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => {
return domEditSaveQueueRef.current?.enqueue(save) ?? save(); return domEditSaveQueueRef.current?.enqueue(save) ?? save();
}, []); }, []);
const waitForPendingDomEditSaves = useCallback(async () => { const drainPendingDomEditSaves = useCallback(async () => {
await flushStudioPendingEdits(); return drainStudioSaveQueues(flushStudioPendingEdits, async () => {
await domEditSaveQueueRef.current?.waitForIdle(); 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(() => { const resetDomEditSaveQueueBreaker = useCallback(() => {
domEditSaveQueueRef.current?.reset(); domEditSaveQueueRef.current?.reset();
setDomEditSaveQueuePaused(null); setDomEditSaveQueuePaused(null);
@@ -235,35 +222,12 @@ export function usePreviewPersistence({
void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile); 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 { return {
domTextCommitVersionRef, domTextCommitVersionRef,
domEditSaveQueueRef, domEditSaveQueueRef,
applyStudioManualEditsToPreviewRef, applyStudioManualEditsToPreviewRef,
queueDomEditSave, queueDomEditSave,
drainPendingDomEditSaves,
waitForPendingDomEditSaves, waitForPendingDomEditSaves,
domEditSaveQueuePaused, domEditSaveQueuePaused,
resetDomEditSaveQueueBreaker, resetDomEditSaveQueueBreaker,
+8 -52
View File
@@ -1,11 +1,11 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import type { MutableRefObject } from "react";
import { openComposition } from "@hyperframes/sdk"; import { openComposition } from "@hyperframes/sdk";
import type { Composition } from "@hyperframes/sdk"; import type { Composition } from "@hyperframes/sdk";
import { readStudioFileChangePath } from "../components/editor/manualEdits"; import { readStudioFileChangePath } from "../components/editor/manualEdits";
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry"; import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
import { trackStudioEvent } from "../utils/studioTelemetry"; import { trackStudioEvent } from "../utils/studioTelemetry";
import type { PublishSdkSession } from "../utils/sdkCutover"; import type { PublishSdkSession } from "../utils/sdkCutover";
import { addExternalFileReloadListener } from "./externalFileReloadBus";
/** /**
* Read a project file's content, or undefined on a non-2xx (optional read). * Read a project file's content, or undefined on a non-2xx (optional read).
@@ -48,22 +48,6 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
* stale. The session has NO persist queue Studio is the sole file writer; see * stale. The session has NO persist queue Studio is the sole file writer; see
* the open effect below. * 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 * 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); * session. `content` is the new on-disk bytes (from the payload or a re-read);
@@ -157,7 +141,6 @@ function disposeSdkSession(session: Composition): void {
export function useSdkSession( export function useSdkSession(
projectId: string | null, projectId: string | null,
activeCompPath: string | null, activeCompPath: string | null,
domEditSaveTimestampRef?: MutableRefObject<number>,
): SdkSessionHandle { ): SdkSessionHandle {
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null); const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
const ownedSessionRef = useRef<OwnedSdkSession | null>(null); const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
@@ -171,40 +154,13 @@ export function useSdkSession(
const reloadTokenRef = useRef(reloadToken); const reloadTokenRef = useRef(reloadToken);
reloadTokenRef.current = reloadToken; reloadTokenRef.current = reloadToken;
// ── Re-open on external change to the active composition ── useEffect(
useEffect(() => { () =>
if (!activeCompPath) return; addExternalFileReloadListener((changedPath) => {
const compPath = activeCompPath; if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
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]);
// ── Open / re-open the session ── // ── Open / re-open the session ──
useEffect(() => { useEffect(() => {
@@ -0,0 +1,81 @@
import { useCallback, type MutableRefObject } from "react";
import {
deleteExternalConflictSnapshot,
loadExternalConflictSnapshot,
persistExternalConflictSnapshot,
persistExternalFailureSnapshot,
} from "../utils/externalConflictStorage";
import { notifyExternalFileReload } from "./externalFileReloadBus";
import { useExternalFileChangeCoordinator } from "./useExternalFileChangeCoordinator";
import type { useFileManager } from "./useFileManager";
import type { usePreviewPersistence } from "./usePreviewPersistence";
type FileManager = Pick<
ReturnType<typeof useFileManager>,
| "editingFile"
| "flushPendingSourceSave"
| "discardPendingSourceSave"
| "getPendingSourceCandidate"
| "overwriteExternalConflict"
| "readProjectFile"
| "updateEditingFileContent"
>;
type PreviewPersistence = Pick<
ReturnType<typeof usePreviewPersistence>,
"drainPendingDomEditSaves" | "resetDomEditSaveQueueBreaker"
>;
interface UseStudioExternalFileChangesOptions {
projectId: string | null;
activeCompPath: string | null;
masterCompPath: string | null;
fileManager: FileManager;
previewPersistence: PreviewPersistence;
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
reloadPreview: () => void;
}
/** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */
export function useStudioExternalFileChanges({
projectId,
activeCompPath,
masterCompPath,
fileManager,
previewPersistence,
pendingTimelineEditPathRef,
reloadPreview,
}: UseStudioExternalFileChangesOptions) {
const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
const drainPendingChanges = useCallback(async () => {
const source = await flushPendingSourceSave();
if (source.status !== "clean") return source;
return drainPendingDomEditSaves();
}, [drainPendingDomEditSaves, flushPendingSourceSave]);
const discardPendingChanges = useCallback(() => {
discardPendingSourceSave();
resetDomEditSaveQueueBreaker();
}, [discardPendingSourceSave, resetDomEditSaveQueueBreaker]);
return useExternalFileChangeCoordinator({
projectId,
activeCompPath,
recoveryFilePath: fileManager.editingFile?.path ?? activeCompPath ?? masterCompPath,
pendingTimelineEditPathRef,
drainPendingChanges,
getPendingCandidate: fileManager.getPendingSourceCandidate,
discardPendingChanges,
reloadPreview,
reloadSdkSession: notifyExternalFileReload,
persistConflictSnapshot: persistExternalConflictSnapshot,
persistFailureSnapshot: persistExternalFailureSnapshot,
loadConflictSnapshot: loadExternalConflictSnapshot,
deleteConflictSnapshot: deleteExternalConflictSnapshot,
overwriteConflict: fileManager.overwriteExternalConflict,
readProjectFile: fileManager.readProjectFile,
onUseExternalFile: fileManager.updateEditingFileContent,
resetSaveQueues: resetDomEditSaveQueueBreaker,
});
}
@@ -1,4 +1,4 @@
import { useEffect, type MutableRefObject } from "react"; import { useEffect } from "react";
import { useSdkSession } from "./useSdkSession"; import { useSdkSession } from "./useSdkSession";
import { usePreviewVariablesStore } from "./previewVariablesStore"; import { usePreviewVariablesStore } from "./previewVariablesStore";
@@ -17,18 +17,13 @@ import { usePreviewVariablesStore } from "./previewVariablesStore";
export function useStudioSdkSessions( export function useStudioSdkSessions(
projectId: string | null, projectId: string | null,
activeCompPath: string | null, activeCompPath: string | null,
domEditSaveTimestampRef: MutableRefObject<number>,
masterCompPath: string | null, masterCompPath: string | null,
) { ) {
// On the master view (no explicit comp) the schema panels target the project's // On the master view (no explicit comp) the schema panels target the project's
// resolved main composition — the first `.html` in the tree, not a hardcoded // resolved main composition — the first `.html` in the tree, not a hardcoded
// "index.html" that may not exist. `null` when the project has no composition // "index.html" that may not exist. `null` when the project has no composition
// yet, which correctly leaves the session (and the panels) empty. // yet, which correctly leaves the session (and the panels) empty.
const sdkHandle = useSdkSession( const sdkHandle = useSdkSession(projectId, activeCompPath ?? masterCompPath);
projectId,
activeCompPath ?? masterCompPath,
domEditSaveTimestampRef,
);
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null; const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
useEffect(() => { useEffect(() => {
usePreviewVariablesStore.getState().setValues(null); usePreviewVariablesStore.getState().setValues(null);
@@ -0,0 +1,15 @@
import { useEffect } from "react";
import { hasFiredSessionStart, markSessionStartFired } from "../telemetry/config";
import { trackStudioSessionStart } from "../telemetry/events";
export function useStudioSessionStart(
projectId: string | null,
resolving: boolean,
waitingForServer: boolean,
): void {
useEffect(() => {
if (resolving || waitingForServer || hasFiredSessionStart()) return;
markSessionStartFired();
trackStudioSessionStart({ has_project: projectId != null });
}, [projectId, resolving, waitingForServer]);
}
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -u
max_attempts=3
retry_delay_seconds="${HF_BUN_INSTALL_RETRY_DELAY_SECONDS:-5}"
for ((attempt = 1; attempt <= max_attempts; attempt += 1)); do
if bun install --frozen-lockfile "$@"; then
exit 0
else
install_status=$?
fi
if ((attempt == max_attempts)); then
echo "::error::Bun dependency install failed after ${max_attempts} attempts."
exit "$install_status"
fi
next_attempt=$((attempt + 1))
echo "::warning::Bun dependency install failed; retrying dependency install (attempt ${next_attempt}/${max_attempts})."
sleep "$((retry_delay_seconds * attempt))"
done
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
const installScript = new URL("./ci/install-workspace-dependencies.sh", import.meta.url);
function runInstall({ succeedOnAttempt }) {
const root = mkdtempSync(join(tmpdir(), "hyperframes-bun-install-test-"));
const binDir = join(root, "bin");
const countFile = join(root, "attempts");
const argsFile = join(root, "args");
try {
spawnSync("mkdir", ["-p", binDir], { stdio: "pipe" });
const bunStub = join(binDir, "bun");
writeFileSync(
bunStub,
`#!/usr/bin/env bash
set -u
count=0
if [[ -f "$COUNT_FILE" ]]; then count="$(cat "$COUNT_FILE")"; fi
count=$((count + 1))
printf '%s' "$count" > "$COUNT_FILE"
printf '%s\\n' "$*" >> "$ARGS_FILE"
if (( count < SUCCEED_ON_ATTEMPT )); then exit 23; fi
`,
);
chmodSync(bunStub, 0o755);
const result = spawnSync("bash", [installScript.pathname, "--ignore-scripts"], {
encoding: "utf8",
env: {
...process.env,
ARGS_FILE: argsFile,
COUNT_FILE: countFile,
HF_BUN_INSTALL_RETRY_DELAY_SECONDS: "0",
PATH: `${binDir}:${process.env.PATH}`,
SUCCEED_ON_ATTEMPT: String(succeedOnAttempt),
},
timeout: 5_000,
});
return {
...result,
args: existsSync(argsFile) ? readFileSync(argsFile, "utf8").trim().split("\n") : [],
attempts: existsSync(countFile) ? Number(readFileSync(countFile, "utf8")) : 0,
};
} finally {
rmSync(root, { recursive: true, force: true });
}
}
test("retries a transient Bun install failure with the same frozen-lockfile arguments", () => {
const result = runInstall({ succeedOnAttempt: 2 });
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.equal(result.attempts, 2);
assert.deepEqual(result.args, [
"install --frozen-lockfile --ignore-scripts",
"install --frozen-lockfile --ignore-scripts",
]);
assert.match(result.stdout, /retrying dependency install \(attempt 2\/3\)/);
});
test("stops after three failed Bun install attempts", () => {
const result = runInstall({ succeedOnAttempt: 4 });
assert.equal(result.status, 23, `${result.stdout}\n${result.stderr}`);
assert.equal(result.attempts, 3);
assert.match(result.stdout, /failed after 3 attempts/);
});