mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
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:
+14
-14
@@ -129,7 +129,7 @@ jobs:
|
||||
- run: corepack enable
|
||||
- run: corepack prepare pnpm@10.17.1 --activate
|
||||
- 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 verify:packed-manifests
|
||||
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||
- run: bun run lint
|
||||
|
||||
# `fallow audit` runs dead-code + complexity + duplication analysis scoped to
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||
- name: Run fallow audit
|
||||
id: audit
|
||||
# `bun install` above made `bunx fallow` resolve from node_modules, so
|
||||
@@ -238,7 +238,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||
- run: bun run format:check
|
||||
|
||||
typecheck:
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- 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 --filter '*' typecheck
|
||||
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- 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 --filter '@hyperframes/{parsers,lint,studio-server}' build
|
||||
- run: bun run --cwd packages/core build
|
||||
@@ -314,7 +314,7 @@ jobs:
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- 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 --cwd packages/core build
|
||||
- run: bun run --filter @hyperframes/engine build
|
||||
@@ -422,7 +422,7 @@ jobs:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
if: runner.os != 'Windows'
|
||||
run: bun install --frozen-lockfile --ignore-scripts
|
||||
run: bash scripts/ci/install-workspace-dependencies.sh --ignore-scripts
|
||||
- name: Install dependencies
|
||||
if: runner.os == 'Windows'
|
||||
run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted
|
||||
@@ -437,7 +437,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- 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
|
||||
# imports resolve via the "node" export condition (dist) under vitest.
|
||||
- run: bun run --filter '@hyperframes/parsers' build
|
||||
@@ -459,7 +459,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- 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
|
||||
# subpaths. Build their dist exports before Vitest resolves them.
|
||||
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
||||
@@ -480,7 +480,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- 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
|
||||
# resolve @hyperframes/core and @hyperframes/studio-server via the "node"
|
||||
# export condition (dist).
|
||||
@@ -523,7 +523,7 @@ jobs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
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
|
||||
# resolves the workspace packages through their "node" export condition.
|
||||
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
|
||||
@@ -634,7 +634,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bash scripts/ci/install-workspace-dependencies.sh
|
||||
- run: bun run build
|
||||
|
||||
# Pack the CLI as a tarball (simulates what `npm publish` produces)
|
||||
@@ -711,7 +711,7 @@ jobs:
|
||||
sudo apt-get install -y ffmpeg
|
||||
- uses: ./.github/actions/prepare-ffmpeg-bin
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: bash scripts/ci/install-workspace-dependencies.sh
|
||||
- name: Build monorepo
|
||||
run: bun run build
|
||||
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@
|
||||
"player:perf": "bun run --filter @hyperframes/player perf",
|
||||
"format:check": "oxfmt --check .",
|
||||
"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'",
|
||||
"generate:previews": "tsx scripts/generate-template-previews.ts",
|
||||
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
|
||||
|
||||
+14
-14
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
@@ -130,7 +126,6 @@ export function StudioApp() {
|
||||
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
|
||||
projectId,
|
||||
activeCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
masterCompPath,
|
||||
);
|
||||
useEffect(() => {
|
||||
@@ -144,20 +139,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,
|
||||
@@ -483,12 +482,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}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function DesignPanelPromoteProvider({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
|
||||
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
|
||||
const handle = useSdkSession(projectId, targetPath);
|
||||
const rawPersist = useVariablesPersist({
|
||||
...persistDeps,
|
||||
sdkSession: handle.session,
|
||||
|
||||
@@ -113,4 +113,30 @@ describe("ExternalFileConflictBanner", () => {
|
||||
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
{failure && !failure.recovered && (
|
||||
{failure && !failure.recovered && failure.studioContent != null && (
|
||||
<button type="button" onClick={() => void coordinator.retry()} className="underline">
|
||||
Retry save
|
||||
</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 {
|
||||
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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
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).
|
||||
@@ -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
|
||||
* 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);
|
||||
@@ -157,7 +141,6 @@ function disposeSdkSession(session: Composition): void {
|
||||
export function useSdkSession(
|
||||
projectId: string | null,
|
||||
activeCompPath: string | null,
|
||||
domEditSaveTimestampRef?: MutableRefObject<number>,
|
||||
): SdkSessionHandle {
|
||||
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
|
||||
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
|
||||
@@ -171,40 +154,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(() => {
|
||||
|
||||
@@ -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 { usePreviewVariablesStore } from "./previewVariablesStore";
|
||||
|
||||
@@ -17,18 +17,13 @@ import { usePreviewVariablesStore } from "./previewVariablesStore";
|
||||
export function useStudioSdkSessions(
|
||||
projectId: string | null,
|
||||
activeCompPath: string | null,
|
||||
domEditSaveTimestampRef: MutableRefObject<number>,
|
||||
masterCompPath: string | null,
|
||||
) {
|
||||
// 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
|
||||
// "index.html" that may not exist. `null` when the project has no composition
|
||||
// yet, which correctly leaves the session (and the panels) empty.
|
||||
const sdkHandle = useSdkSession(
|
||||
projectId,
|
||||
activeCompPath ?? masterCompPath,
|
||||
domEditSaveTimestampRef,
|
||||
);
|
||||
const sdkHandle = useSdkSession(projectId, activeCompPath ?? masterCompPath);
|
||||
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
|
||||
useEffect(() => {
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user