diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e79a49359..e19b5ce0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package.json b/package.json index 870deec88..46fb3ceb5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 24b69e88d..ffafec908 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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(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 && ( )} + {viewModeValue.viewMode === "storyboard" && ( { 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()); + expect(document.body.textContent).not.toContain("Retry save"); + expect(document.body.textContent).toContain("Discard Studio edits and reload file"); + + await act(async () => root.unmount()); + }); }); diff --git a/packages/studio/src/components/ExternalFileConflictBanner.tsx b/packages/studio/src/components/ExternalFileConflictBanner.tsx index 1abddf20c..5ae4c5c9b 100644 --- a/packages/studio/src/components/ExternalFileConflictBanner.tsx +++ b/packages/studio/src/components/ExternalFileConflictBanner.tsx @@ -193,7 +193,7 @@ export function ExternalFileConflictBanner({ Review or export Studio draft )} - {failure && !failure.recovered && ( + {failure && !failure.recovered && failure.studioContent != null && ( diff --git a/packages/studio/src/hooks/externalFileChangeOwnership.test.ts b/packages/studio/src/hooks/externalFileChangeOwnership.test.ts new file mode 100644 index 000000000..4763cd5ee --- /dev/null +++ b/packages/studio/src/hooks/externalFileChangeOwnership.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/hooks/externalFileReloadBus.test.ts b/packages/studio/src/hooks/externalFileReloadBus.test.ts new file mode 100644 index 000000000..79e871110 --- /dev/null +++ b/packages/studio/src/hooks/externalFileReloadBus.test.ts @@ -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(); + }); +}); diff --git a/packages/studio/src/hooks/externalFileReloadBus.ts b/packages/studio/src/hooks/externalFileReloadBus.ts new file mode 100644 index 000000000..f743de0a6 --- /dev/null +++ b/packages/studio/src/hooks/externalFileReloadBus.ts @@ -0,0 +1,18 @@ +type ExternalFileReloadListener = (path: string) => void; + +const listeners = new Set(); + +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. + } + } +} diff --git a/packages/studio/src/hooks/usePreviewPersistence.test.ts b/packages/studio/src/hooks/usePreviewPersistence.test.ts new file mode 100644 index 000000000..b90cdfe61 --- /dev/null +++ b/packages/studio/src/hooks/usePreviewPersistence.test.ts @@ -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(); + }); +}); diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index 553012271..608220eda 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -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; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (entry: RecordEditInput) => Promise; previewIframeRef: React.MutableRefObject; activeCompPathRef: React.MutableRefObject; - /** 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; - /** 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>; /** 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> | undefined, - domEditSaveTimestampRef: React.MutableRefObject, -): 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, + waitForDomQueue: () => Promise, +): Promise { + 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((save: () => Promise): Promise => { 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 => { + 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, diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 31886433e..8cd5cea1c 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -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; - 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, ): SdkSessionHandle { const [ownedSession, setOwnedSession] = useState(null); const ownedSessionRef = useRef(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(() => { diff --git a/packages/studio/src/hooks/useStudioExternalFileChanges.ts b/packages/studio/src/hooks/useStudioExternalFileChanges.ts new file mode 100644 index 000000000..57ccd9b82 --- /dev/null +++ b/packages/studio/src/hooks/useStudioExternalFileChanges.ts @@ -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, + | "editingFile" + | "flushPendingSourceSave" + | "discardPendingSourceSave" + | "getPendingSourceCandidate" + | "overwriteExternalConflict" + | "readProjectFile" + | "updateEditingFileContent" +>; + +type PreviewPersistence = Pick< + ReturnType, + "drainPendingDomEditSaves" | "resetDomEditSaveQueueBreaker" +>; + +interface UseStudioExternalFileChangesOptions { + projectId: string | null; + activeCompPath: string | null; + masterCompPath: string | null; + fileManager: FileManager; + previewPersistence: PreviewPersistence; + pendingTimelineEditPathRef: MutableRefObject>; + 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, + }); +} diff --git a/packages/studio/src/hooks/useStudioSdkSessions.ts b/packages/studio/src/hooks/useStudioSdkSessions.ts index bc3d51b2d..6d3b85399 100644 --- a/packages/studio/src/hooks/useStudioSdkSessions.ts +++ b/packages/studio/src/hooks/useStudioSdkSessions.ts @@ -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, 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); diff --git a/packages/studio/src/hooks/useStudioSessionStart.ts b/packages/studio/src/hooks/useStudioSessionStart.ts new file mode 100644 index 000000000..cee75ebbd --- /dev/null +++ b/packages/studio/src/hooks/useStudioSessionStart.ts @@ -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]); +} diff --git a/scripts/ci/install-workspace-dependencies.sh b/scripts/ci/install-workspace-dependencies.sh new file mode 100644 index 000000000..93018bf4a --- /dev/null +++ b/scripts/ci/install-workspace-dependencies.sh @@ -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 diff --git a/scripts/install-workspace-dependencies.test.mjs b/scripts/install-workspace-dependencies.test.mjs new file mode 100644 index 000000000..82d657072 --- /dev/null +++ b/scripts/install-workspace-dependencies.test.mjs @@ -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/); +});