diff --git a/packages/cli/src/server/fileWatcher.fs.test.ts b/packages/cli/src/server/fileWatcher.fs.test.ts index f534665fd5d..b483a4b6dff 100644 --- a/packages/cli/src/server/fileWatcher.fs.test.ts +++ b/packages/cli/src/server/fileWatcher.fs.test.ts @@ -14,11 +14,10 @@ describe("createProjectWatcher on a real directory", () => { rmSync(dir, { recursive: true, force: true }); }); - const watchProject = async () => { + const watchProject = (projectDir = dir) => { const seen: string[] = []; - watcher = createProjectWatcher(dir); + watcher = createProjectWatcher(projectDir); watcher.addListener((path) => seen.push(path)); - await new Promise((resolve) => setTimeout(resolve, 100)); return seen; }; const expectReported = async (seen: string[], path: string) => { @@ -35,7 +34,7 @@ describe("createProjectWatcher on a real directory", () => { mkdirSync(join(dir, "compositions")); writeFileSync(join(dir, "index.html"), "v0"); writeFileSync(join(dir, "compositions", "scene.html"), "v0"); - const seen = await watchProject(); + const seen = watchProject(); for (const path of ["index.html", join("compositions", "scene.html")]) { replaceByRename(join(dir, path), "stamped"); @@ -47,10 +46,71 @@ describe("createProjectWatcher on a real directory", () => { } }); + it.runIf(process.platform === "linux")( + "follows a project folder replaced by rename", + async () => { + dir = mkdtempSync(join(tmpdir(), "hf-watch-root-")); + const project = join(dir, "project"); + const replacement = join(dir, "replacement"); + mkdirSync(project); + mkdirSync(join(replacement, "scenes"), { recursive: true }); + writeFileSync(join(project, "index.html"), "before"); + writeFileSync(join(replacement, "index.html"), "after"); + writeFileSync(join(replacement, "scenes", "intro.html"), "new scene"); + const seen = watchProject(project); + + renameSync(project, join(dir, "previous")); + renameSync(replacement, project); + await expectReported(seen, "."); + writeFileSync(join(project, "index.html"), "edited root"); + await expectReported(seen, "index.html"); + writeFileSync(join(project, "scenes", "intro.html"), "edited scene"); + await expectReported(seen, join("scenes", "intro.html")); + }, + ); + + it.runIf(process.platform === "linux")( + "re-arms after the project path is absent between replacements", + async () => { + dir = mkdtempSync(join(tmpdir(), "hf-watch-gap-")); + const project = join(dir, "project"); + mkdirSync(project); + writeFileSync(join(project, "index.html"), "before"); + const seen = watchProject(project); + + renameSync(project, join(dir, "previous")); + await expectReported(seen, "."); + mkdirSync(project); + writeFileSync(join(project, "index.html"), "after"); + await expectReported(seen, "."); + writeFileSync(join(project, "index.html"), "later edit"); + await expectReported(seen, "index.html"); + }, + ); + + it.runIf(process.platform === "linux")( + "follows a replaced subdirectory's new inode", + async () => { + dir = mkdtempSync(join(tmpdir(), "hf-watch-subdir-")); + const scenes = join(dir, "scenes"); + mkdirSync(scenes); + mkdirSync(join(dir, "replacement")); + writeFileSync(join(scenes, "intro.html"), "before"); + writeFileSync(join(dir, "replacement", "intro.html"), "after"); + const seen = watchProject(); + + renameSync(scenes, join(dir, "previous")); + renameSync(join(dir, "replacement"), scenes); + await expectReported(seen, "scenes"); + writeFileSync(join(scenes, "intro.html"), "later edit"); + await expectReported(seen, join("scenes", "intro.html")); + }, + ); + it("reports Studio's manifest writes inside .hyperframes", async () => { dir = mkdtempSync(join(tmpdir(), "hf-watch-")); mkdirSync(join(dir, ".hyperframes")); - const seen = await watchProject(); + const seen = watchProject(); writeFileSync(join(dir, ".hyperframes", "studio-motion.json"), "{}"); await expectReported(seen, join(".hyperframes", "studio-motion.json")); @@ -60,7 +120,7 @@ describe("createProjectWatcher on a real directory", () => { dir = mkdtempSync(join(tmpdir(), "hf-watch-")); mkdirSync(join(dir, "scene")); mkdirSync(join(dir, "scenes")); - const seen = await watchProject(); + const seen = watchProject(); rmSync(join(dir, "scene"), { recursive: true }); await expectReported(seen, "scene"); @@ -70,7 +130,7 @@ describe("createProjectWatcher on a real directory", () => { it("reports files in a directory created after it started", async () => { dir = mkdtempSync(join(tmpdir(), "hf-watch-")); - const seen = await watchProject(); + const seen = watchProject(); mkdirSync(join(dir, "scenes")); await expectReported(seen, "scenes"); diff --git a/packages/cli/src/server/fileWatcher.test.ts b/packages/cli/src/server/fileWatcher.test.ts index 05d1b495e25..9e693d6b457 100644 --- a/packages/cli/src/server/fileWatcher.test.ts +++ b/packages/cli/src/server/fileWatcher.test.ts @@ -97,6 +97,29 @@ describe("createProjectWatcher", () => { }, ); + it.runIf(process.platform === "linux")("closes every watch it opened, its parent's too", () => { + const projectWatcher = createProjectWatcher("/fake/project/dir"); + const opened = vi.mocked(watch).mock.calls.map(([path]) => path); + expect(opened).toContain("/fake/project"); + projectWatcher.close(); + expect(mockWatcher.close).toHaveBeenCalledTimes(opened.length); + }); + + it.runIf(process.platform === "linux")( + "keeps reporting project files when its parent cannot be watched", + () => { + vi.useFakeTimers(); + fakeDirs.unwatchable = "/fake/project"; + const projectWatcher = createProjectWatcher("/fake/project/dir"); + const listener = vi.fn(); + projectWatcher.addListener(listener); + mockWatcher.emit("change", "change", "index.html"); + vi.advanceTimersByTime(30); + expect(listener).toHaveBeenCalledExactlyOnceWith("index.html"); + projectWatcher.close(); + }, + ); + it("degrades to no live reload when the project root cannot be watched", () => { fakeDirs.unwatchable = "/fake/project/dir"; let projectWatcher: ReturnType | null = null; diff --git a/packages/cli/src/server/fileWatcher.ts b/packages/cli/src/server/fileWatcher.ts index 1f04477724b..fa77299df07 100644 --- a/packages/cli/src/server/fileWatcher.ts +++ b/packages/cli/src/server/fileWatcher.ts @@ -1,7 +1,10 @@ -import { lstatSync, readdirSync, watch, type FSWatcher } from "node:fs"; -import { join, relative, sep } from "node:path"; +import { lstatSync, readdirSync, watch, type Dirent, type FSWatcher } from "node:fs"; +import { basename, dirname, join, relative, sep } from "node:path"; import { isAtomicTempPath } from "@hyperframes/core/atomic-file"; -import { affectsProjectSignature } from "@hyperframes/studio-server"; +import { + affectsProjectSignature, + STUDIO_SIGNATURE_MANIFEST_PATHS, +} from "@hyperframes/studio-server"; export type FileChangeListener = (relativePath: string) => void; @@ -70,8 +73,8 @@ function watchProjectTree( } } }; - const watchDirectory = (dir: string) => { - if (directories.has(dir)) return; + const watchDirectory = (dir: string, movedIn = false) => { + if (directories.has(dir)) unwatch(dir); let watcher: FSWatcher; try { watcher = watch(dir, { persistent: true }, (event, name) => { @@ -79,34 +82,54 @@ function watchProjectTree( const path = join(dir, name.toString()); onChange(relative(projectDir, path)); if (event !== "rename") return; - if (isDirectory(path)) descend(path); + if (isDirectory(path)) descend(path, true); else unwatch(path); }); - } catch (error) { - // One unwatchable subdirectory (EACCES, inotify limit) must not cost the rest of the tree. - if (dir === projectDir) throw error; + } catch { + // A directory can vanish during replacement; its parent reports its return. return; } watcher.on("error", () => unwatch(dir)); directories.set(dir, watcher); - let entries: string[] = []; + walkChildren(dir, movedIn); + }; + const walkChildren = (dir: string, movedIn: boolean) => { + let entries: Dirent[] = []; try { - entries = readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => join(dir, entry.name)); + entries = readdirSync(dir, { withFileTypes: true }); } catch { // Gone before we could list it; its parent reports the removal. } - for (const child of entries) descend(child); + for (const entry of entries) { + const child = join(dir, entry.name); + // A folder moved in brings files that never get an event of their own, and drops others. + if (entry.isDirectory()) descend(child, movedIn); + else if (movedIn) onChange(relative(projectDir, child)); + } + if (movedIn && relative(projectDir, dir) === ".hyperframes") { + for (const manifest of STUDIO_SIGNATURE_MANIFEST_PATHS) onChange(manifest); + } }; // `.hyperframes/` itself holds the two manifests the signature reads; nothing below it matters. - const descend = (dir: string) => { + const descend = (dir: string, movedIn = false) => { const rel = relative(projectDir, dir); - if (shouldWatchProjectFile(rel) || rel === ".hyperframes") watchDirectory(dir); + if (shouldWatchProjectFile(rel) || rel === ".hyperframes") watchDirectory(dir, movedIn); }; + let parent: FSWatcher | null = null; + try { + parent = watch(dirname(projectDir), { persistent: true }, (event, name) => { + if (event !== "rename" || name?.toString() !== basename(projectDir)) return; + watchDirectory(projectDir); + onChange("."); + }); + parent.on("error", () => parent?.close()); + } catch { + // The project can remain watchable even when its parent is not. + } watchDirectory(projectDir); return () => { + parent?.close(); for (const watcher of directories.values()) watcher.close(); directories.clear(); }; @@ -121,11 +144,8 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher { try { closeTree = watchProjectTree(projectDir, (relativePath) => { - // The reload filter excludes all of `.hyperframes/`, but two files in - // there feed the preview signature and Studio writes one of them at - // runtime — dropping those at ingest left the CLI server's ETag stale - // until restart. Admit them here and let the reload listener re-apply - // its own filter, so what triggers a browser reload is unchanged. + // Studio's two manifests affect the signature despite the .hyperframes exclusion. + // Admit them here; the reload listener still applies its own filter. if ( !shouldWatchProjectFile(relativePath) && !affectsProjectSignature(projectDir, join(projectDir, relativePath)) diff --git a/packages/cli/src/server/studioServer.folderReplacement.test.ts b/packages/cli/src/server/studioServer.folderReplacement.test.ts new file mode 100644 index 00000000000..acc22ce8c8c --- /dev/null +++ b/packages/cli/src/server/studioServer.folderReplacement.test.ts @@ -0,0 +1,132 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { compositionsAffectedBy } from "@hyperframes/studio-server"; +import { createStudioServer, type StudioServer } from "./studioServer.js"; +import { cleanupStudioServerRoot, makeStudioServerRoot } from "./studioServerTestFixture.js"; + +let root: string; +let server: StudioServer | undefined; +let reader: ReadableStreamDefaultReader | undefined; + +afterEach(async () => { + await reader?.cancel(); + if (server) cleanupStudioServerRoot(server, root); +}); + +it.runIf(process.platform === "linux")( + "refreshes every composition and cached preview when its project directory is replaced", + async () => { + const fixture = makeStudioServerRoot("hf-preview-replace-"); + root = fixture.root; + const { projectDir } = fixture; + const rootHtml = (label: string) => ` +
+

${label}

+
`; + const scene = ( + text: string, + ) => ``; + mkdirSync(join(projectDir, "scenes")); + writeFileSync(join(projectDir, "index.html"), rootHtml("Initial")); + writeFileSync(join(projectDir, "scenes/intro.html"), scene("Original")); + expect(compositionsAffectedBy(projectDir, "index.html")).toBeNull(); + writeFileSync(join(projectDir, "index.html"), rootHtml("Unchanged root")); + server = createStudioServer({ projectDir, projectName: "film" }); + expect(compositionsAffectedBy(projectDir, "index.html")).toEqual(["index.html"]); + const app = server.app; + const endpoint = "/api/projects/film/preview/comp/scenes/intro.html"; + const before = await app.request(endpoint); + expect(before.status).toBe(200); + expect(await before.text()).toContain("Original"); + const beforeTag = before.headers.get("etag"); + expect(beforeTag).toBeTruthy(); + + const response = await app.request("/api/events"); + reader = response.body!.getReader(); + const deliveries: string[] = []; + const consume = async () => { + while (true) { + const next = await reader!.read(); + if (next.done) return; + deliveries.push(new TextDecoder().decode(next.value)); + } + }; + const consuming = consume(); + try { + const replacement = join(root, "replacement"); + mkdirSync(join(replacement, "scenes"), { recursive: true }); + writeFileSync(join(replacement, "index.html"), readFileSync(join(projectDir, "index.html"))); + writeFileSync(join(replacement, "scenes/intro.html"), scene("Replacement")); + renameSync(projectDir, join(root, "previous")); + renameSync(replacement, projectDir); + + await vi.waitFor(() => { + const event = deliveries + .join("") + .split("\n") + .find((line) => line.startsWith("data:") && line.includes('"path":"."')); + expect(event).toBeDefined(); + expect(JSON.parse(event!.slice(5))).toMatchObject({ + path: ".", + projectId: "film", + affectsPreview: true, + affectedCompositions: null, + }); + }); + const after = await app.request(endpoint, { headers: { "If-None-Match": beforeTag! } }); + expect(after.status).toBe(200); + expect(after.headers.get("etag")).not.toBe(beforeTag); + expect(await after.text()).toContain("Replacement"); + + writeFileSync(join(projectDir, "scenes/intro.html"), scene("Later edit")); + await vi.waitFor(async () => { + const later = await app.request(endpoint); + expect(later.status).toBe(200); + expect(await later.text()).toContain("Later edit"); + }); + } finally { + await reader.cancel(); + await consuming; + } + }, +); + +it.runIf(process.platform === "linux").each([ + { kept: "a changed manifest", file: "studio-motion.json", content: '{"intro":{"opacity":0.5}}' }, + { kept: "no manifest", file: "arbitrary.txt", content: "not a manifest" }, +])( + "refreshes the cached preview when a populated .hyperframes folder is replaced by one with $kept", + async ({ file, content }) => { + const fixture = makeStudioServerRoot("hf-preview-replace-manifests-"); + root = fixture.root; + const { projectDir } = fixture; + writeFileSync( + join(projectDir, "index.html"), + `
`, + ); + mkdirSync(join(projectDir, ".hyperframes")); + writeFileSync(join(projectDir, ".hyperframes/studio-motion.json"), "{}"); + server = createStudioServer({ projectDir, projectName: "film" }); + const endpoint = "/api/projects/film/preview/comp/index.html"; + const before = await server.app.request(endpoint); + expect(before.status).toBe(200); + const beforeTag = before.headers.get("etag"); + expect(beforeTag).toBeTruthy(); + + const replacement = join(root, "next-manifests"); + mkdirSync(replacement); + writeFileSync(join(replacement, file), content); + renameSync(join(projectDir, ".hyperframes"), join(root, "previous-manifests")); + renameSync(replacement, join(projectDir, ".hyperframes")); + + await vi.waitFor(async () => { + const after = await server!.app.request(endpoint, { + headers: { "If-None-Match": beforeTag! }, + }); + expect(after.status).toBe(200); + expect(after.headers.get("etag")).not.toBe(beforeTag); + }); + }, +); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 54a3ef1151c..ee293d316eb 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -425,7 +425,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId }; let cachedProjectSignature: string | null = null; watcher.addListener((changedPath) => { - if (affectsProjectSignature(projectDir, join(projectDir, changedPath))) { + if (changedPath === "." || affectsProjectSignature(projectDir, join(projectDir, changedPath))) { cachedProjectSignature = null; } }); diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index 11813b229cf..968d2a3cc40 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -1,6 +1,10 @@ export { createStudioApi } from "./createStudioApi.js"; export { MAX_UPLOAD_BYTES } from "./routes/files.js"; -export { createProjectSignature, affectsProjectSignature } from "./helpers/projectSignature.js"; +export { + createProjectSignature, + affectsProjectSignature, + STUDIO_SIGNATURE_MANIFEST_PATHS, +} from "./helpers/projectSignature.js"; export { compositionsAffectedBy } from "./helpers/compositionInputs.js"; export { affectsPreview } from "./helpers/previewReads.js"; export * from "./history/index.js"; diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 190027ad84b..eb408620464 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -10,7 +10,7 @@ export { } from "./manualEditsTypes"; export { - mergeFileChangeAffectedCompositions, + mergeFileChangePayloads, readFileChangeAffectedCompositions, readFileChangeAffectsPreview, readFileChangeField, diff --git a/packages/studio/src/components/editor/manualEditsParsing.ts b/packages/studio/src/components/editor/manualEditsParsing.ts index f2964d69108..e11e1415a49 100644 --- a/packages/studio/src/components/editor/manualEditsParsing.ts +++ b/packages/studio/src/components/editor/manualEditsParsing.ts @@ -44,9 +44,21 @@ export function readFileChangeAffectedCompositions(payload: unknown): readonly s return value.map(normalizeStudioFileChangePath); } -export function mergeFileChangeAffectedCompositions(waiting: unknown, incoming: unknown): unknown { +export function mergeFileChangePayloads(waiting: unknown, incoming: unknown): unknown { const before = readFileChangeAffectedCompositions(waiting); const after = readFileChangeAffectedCompositions(incoming); const merged = before && after ? [...new Set([...before, ...after])] : null; - return { ...asPayloadRecord(incoming), affectedCompositions: merged }; + const next = asPayloadRecord(incoming); + const affectsPreview = + readFileChangeAffectsPreview(waiting) || readFileChangeAffectsPreview(incoming); + if (readStudioFileChangePath(waiting) === readStudioFileChangePath(incoming)) { + return { ...next, affectsPreview, affectedCompositions: merged }; + } + // Changes to two files are owed: only a project-wide reload ("." path) covers both. + return { + path: ".", + projectId: next?.projectId, + affectsPreview, + affectedCompositions: merged, + }; } diff --git a/packages/studio/src/hooks/externalFileChangeOwnership.test.ts b/packages/studio/src/hooks/externalFileChangeOwnership.test.ts index 27dcdbc6bc3..3c57eb010b5 100644 --- a/packages/studio/src/hooks/externalFileChangeOwnership.test.ts +++ b/packages/studio/src/hooks/externalFileChangeOwnership.test.ts @@ -21,9 +21,30 @@ describe("external file-change subscription ownership", () => { new URL("./useExternalFileChangeCoordinator.ts", import.meta.url), "utf8", ); - // vitest defines `import.meta.hot`, so the selection is the one claim left to - // the source; the channel's own behaviour is exercised below. - expect(coordinator.match(/sseFileChangeChannel\(handler\)/g)).toHaveLength(1); + expect(coordinator.match(/sseFileChangeChannel\(handler, catchUp\)/g)).toHaveLength(1); + }); + + it("asks to catch up on every reconnect, since changes made while disconnected are never resent", () => { + const listeners = new Map void>(); + class FakeEventSource { + addEventListener(type: string, listener: (event: unknown) => void) { + listeners.set(type, listener); + } + close = vi.fn(); + } + vi.stubGlobal("EventSource", FakeEventSource); + try { + const onReconnect = vi.fn(); + sseFileChangeChannel(vi.fn(), onReconnect); + listeners.get("open")?.(new Event("open")); + expect(onReconnect).not.toHaveBeenCalled(); + listeners.get("error")?.(new Event("error")); + listeners.get("open")?.(new Event("open")); + listeners.get("open")?.(new Event("open")); + expect(onReconnect).toHaveBeenCalledTimes(2); + } finally { + vi.unstubAllGlobals(); + } }); it("delivers the raw file-change event from EventSource to the handler and closes on cleanup", () => { @@ -42,7 +63,7 @@ describe("external file-change subscription ownership", () => { vi.stubGlobal("EventSource", FakeEventSource); try { const onDelivery = vi.fn(); - const stop = sseFileChangeChannel(onDelivery); + const stop = sseFileChangeChannel(onDelivery, vi.fn()); const event = new MessageEvent("file-change", { data: JSON.stringify({ path: "a.html" }) }); listeners.get("file-change")?.(event); expect(opened).toEqual(["/api/events"]); diff --git a/packages/studio/src/hooks/useExternalFileChangeCoordinator.test.tsx b/packages/studio/src/hooks/useExternalFileChangeCoordinator.test.tsx index 50a7a5136df..d146325a1a3 100644 --- a/packages/studio/src/hooks/useExternalFileChangeCoordinator.test.tsx +++ b/packages/studio/src/hooks/useExternalFileChangeCoordinator.test.tsx @@ -41,7 +41,11 @@ async function mountCoordinator(overrides: Partial = {}) { return null; } await act(async () => root.render()); - return { captured, options }; + const rerender = async (next: Partial) => { + Object.assign(options, next); + await act(async () => root.render()); + }; + return { captured, options, rerender }; } describe("external file change coordinator", () => { @@ -64,6 +68,443 @@ describe("external file change coordinator", () => { vi.unstubAllGlobals(); }); + describe("SSE reconnect recovery", () => { + let source: EventTarget; + let close: ReturnType; + + beforeEach(() => { + vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", undefined); + source = new EventTarget(); + close = vi.fn(); + vi.stubGlobal( + "EventSource", + class { + addEventListener = source.addEventListener.bind(source); + close = close; + }, + ); + }); + + const open = () => + act(async () => { + source.dispatchEvent(new Event("open")); + }); + const send = (payload: object) => + act(async () => { + source.dispatchEvent(new MessageEvent("file-change", { data: JSON.stringify(payload) })); + }); + + it("delivers the held reconnect scope when a queued clean drain precedes React's commit", async () => { + let finishAcceptance = () => {}; + const accepted = new Promise((resolve) => { + finishAcceptance = resolve; + }); + const blockedAtSecondDrain: Array = []; + const onAcceptedPersistedFileChange = vi.fn(() => finishAcceptance()); + const drainPendingChanges = vi.fn(async () => { + if (drainPendingChanges.mock.calls.length === 1) { + return { status: "failed" as const, error: new Error("save rejected") }; + } + blockedAtSecondDrain.push(captured.handle?.blocked ?? null); + return { status: "clean" as const }; + }); + const { captured, options } = await mountCoordinator({ + activeCompPath: "film.html", + drainPendingChanges, + onAcceptedPersistedFileChange, + refreshFileTree: vi.fn(), + }); + await open(); + await act(async () => { + source.dispatchEvent(new Event("open")); + source.dispatchEvent( + new MessageEvent("file-change", { + data: JSON.stringify({ + path: "scenes/nested.html", + affectsPreview: false, + affectedCompositions: ["scenes/nested.html"], + }), + }), + ); + await accepted; + }); + + expect(blockedAtSecondDrain).toEqual([null]); + expect(drainPendingChanges).toHaveBeenCalledTimes(2); + expect(options.reloadPreview).toHaveBeenCalledOnce(); + expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith("."); + expect(onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null); + expect(options.refreshFileTree).toHaveBeenCalledOnce(); + expect(captured.handle?.blocked).toBeNull(); + }); + + it("waits for pending edits before refreshing Preview, SDK, thumbnails and tree on each reconnect", async () => { + let finishDrain = () => {}; + const pendingEdit = new Promise((resolve) => { + finishDrain = resolve; + }); + const { options } = await mountCoordinator({ + drainPendingChanges: vi.fn(async () => { + await pendingEdit; + return { status: "clean" as const }; + }), + refreshFileTree: vi.fn(), + }); + await open(); + expect(options.drainPendingChanges).not.toHaveBeenCalled(); + await open(); + expect(options.drainPendingChanges).toHaveBeenCalledOnce(); + expect(options.reloadPreview).not.toHaveBeenCalled(); + expect(options.reloadSdkSession).not.toHaveBeenCalled(); + await act(async () => finishDrain()); + expect(options.reloadPreview).toHaveBeenCalledOnce(); + expect(options.reloadSdkSession).toHaveBeenCalledWith("."); + expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledWith(".", null); + expect(options.refreshFileTree).toHaveBeenCalledOnce(); + await open(); + expect(options.reloadPreview).toHaveBeenCalledTimes(2); + expect(options.reloadSdkSession).toHaveBeenCalledTimes(2); + expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2); + expect(options.refreshFileTree).toHaveBeenCalledTimes(2); + }); + + it("reloads every session for a project-directory change, not just the current composition", async () => { + let finishDrain = () => {}; + const pendingEdit = new Promise((resolve) => { + finishDrain = resolve; + }); + const { options } = await mountCoordinator({ + activeCompPath: "scenes/intro.html", + drainPendingChanges: vi.fn(async () => { + await pendingEdit; + return { status: "clean" as const }; + }), + }); + await act(async () => { + source.dispatchEvent( + new MessageEvent("file-change", { + data: JSON.stringify({ + path: ".", + projectId: "project-a", + affectsPreview: true, + affectedCompositions: null, + }), + }), + ); + }); + expect(options.drainPendingChanges).toHaveBeenCalledOnce(); + expect(options.reloadPreview).not.toHaveBeenCalled(); + expect(options.reloadSdkSession).not.toHaveBeenCalled(); + await act(async () => finishDrain()); + expect(options.reloadPreview).toHaveBeenCalledOnce(); + expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith("."); + expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null); + }); + + it("holds a reconnect behind a persisted conflict until the user accepts the external file", async () => { + const conflict = new StudioFileConflictError({ + filePath: "index.html", + currentVersion: "v2", + currentContent: "external", + attemptedContent: "studio", + }); + const { captured, options } = await mountCoordinator({ + drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })), + }); + await open(); + await open(); + expect(options.persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict); + expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict }); + expect(options.reloadPreview).not.toHaveBeenCalled(); + expect(options.reloadSdkSession).not.toHaveBeenCalled(); + expect(options.onAcceptedPersistedFileChange).not.toHaveBeenCalled(); + await act(async () => captured.handle?.useExternalFile()); + expect(options.discardPendingChanges).toHaveBeenCalledOnce(); + expect(options.reloadPreview).toHaveBeenCalledOnce(); + expect(options.reloadSdkSession).toHaveBeenCalledWith("."); + }); + + it("reloads every session after navigation and when no composition is selected", async () => { + const { options, rerender } = await mountCoordinator(); + await open(); + await rerender({ activeCompPath: "scenes/next.html" }); + await open(); + expect(options.reloadSdkSession).toHaveBeenLastCalledWith("."); + await rerender({ activeCompPath: null }); + await open(); + expect(options.reloadSdkSession).toHaveBeenCalledTimes(2); + expect(options.reloadSdkSession).toHaveBeenLastCalledWith("."); + expect(close).not.toHaveBeenCalled(); + }); + + it("keeps a reconnect's project-wide reload when a file change queues behind it", async () => { + let finishDrain = () => {}; + const pendingEdit = new Promise((resolve) => { + finishDrain = resolve; + }); + const reloadSdkSession = vi.fn(); + await mountCoordinator({ + reloadSdkSession, + drainPendingChanges: vi + .fn() + .mockImplementationOnce(async () => { + await pendingEdit; + return { status: "clean" as const }; + }) + .mockImplementation(async () => ({ status: "clean" as const })), + }); + await send({ path: "notes.md", version: "n1" }); + await open(); + await open(); + await send({ path: "index.html", version: "v2" }); + await act(async () => finishDrain()); + expect(reloadSdkSession.mock.calls.map(([path]) => path)).toEqual(["notes.md", "."]); + }); + + it("delivers a held reconnect's project-wide reload when a later change saves cleanly", async () => { + const { options } = await mountCoordinator({ + drainPendingChanges: vi + .fn() + .mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") }) + .mockResolvedValue({ status: "clean" as const }), + getPendingCandidate: () => ({ path: "script.js", content: "unsaved script" }), + persistFailureSnapshot: vi.fn(async () => undefined), + }); + await open(); + await open(); + expect(options.reloadSdkSession).not.toHaveBeenCalled(); + await send({ path: "scenes/nested.html", version: "n1", affectsPreview: false }); + expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith("."); + expect(options.reloadPreview).toHaveBeenCalledOnce(); + expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null); + }); + + it("keeps the unsaved draft of the file open in the code panel when a reconnect cannot save it", async () => { + const failure = new Error("network unavailable"); + const persistFailureSnapshot = vi.fn(async () => undefined); + const readProjectFile = vi.fn(async () => "disk script"); + const onUseExternalFile = vi.fn(); + const { captured, options } = await mountCoordinator({ + drainPendingChanges: vi.fn(async () => ({ status: "failed" as const, error: failure })), + getPendingCandidate: () => ({ path: "script.js", content: "unsaved script" }), + persistFailureSnapshot, + readProjectFile, + onUseExternalFile, + }); + await open(); + await open(); + expect(captured.handle?.blocked).toMatchObject({ + status: "failed", + path: "script.js", + studioContent: "unsaved script", + }); + expect(persistFailureSnapshot).toHaveBeenCalledExactlyOnceWith( + "project-a", + "script.js", + "unsaved script", + null, + null, + failure, + ); + await act(async () => captured.handle?.useExternalFile()); + expect(readProjectFile).toHaveBeenCalledWith("script.js"); + expect(onUseExternalFile).toHaveBeenCalledWith("script.js", "disk script"); + expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith("."); + }); + + it("keeps the drafted file's recovery off another file's external version", async () => { + const failure = new Error("network unavailable"); + const persistFailureSnapshot = vi.fn(async () => undefined); + const { captured } = await mountCoordinator({ + drainPendingChanges: vi.fn(async () => ({ status: "failed" as const, error: failure })), + getPendingCandidate: () => ({ path: "script.js", content: "unsaved script" }), + persistFailureSnapshot, + }); + await send({ path: "index.html", version: "v2", content: "agent html" }); + expect(persistFailureSnapshot).toHaveBeenCalledExactlyOnceWith( + "project-a", + "script.js", + "unsaved script", + null, + null, + failure, + ); + expect(captured.handle?.blocked).toMatchObject({ + path: "script.js", + payload: { path: "index.html" }, + }); + }); + + it("keeps foreign project changes out of recovery", async () => { + const { options } = await mountCoordinator(); + await open(); + await act(async () => { + source.dispatchEvent( + new MessageEvent("file-change", { + data: JSON.stringify({ path: "index.html", projectId: "project-b", version: "v2" }), + }), + ); + }); + expect(options.drainPendingChanges).not.toHaveBeenCalled(); + await open(); + expect(options.reloadPreview).toHaveBeenCalledOnce(); + }); + + it("uses the new project's owner when a reconnect arrives during the previous drain", async () => { + let finishDrain = () => {}; + const pendingEdit = new Promise((resolve) => { + finishDrain = resolve; + }); + const first = { + drainPendingChanges: vi.fn(async () => { + await pendingEdit; + return { status: "clean" as const }; + }), + reloadPreview: vi.fn(), + reloadSdkSession: vi.fn(), + onAcceptedPersistedFileChange: vi.fn(), + }; + const { rerender } = await mountCoordinator(first); + await open(); + await open(); + const next = { + projectId: "project-b", + activeCompPath: "scenes/next.html", + drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })), + reloadPreview: vi.fn(), + reloadSdkSession: vi.fn(), + onAcceptedPersistedFileChange: vi.fn(), + }; + await rerender(next); + await open(); + expect(next.drainPendingChanges).not.toHaveBeenCalled(); + await act(async () => finishDrain()); + expect(first.drainPendingChanges).toHaveBeenCalledOnce(); + expect(first.reloadPreview).not.toHaveBeenCalled(); + expect(first.reloadSdkSession).not.toHaveBeenCalled(); + expect(first.onAcceptedPersistedFileChange).not.toHaveBeenCalled(); + expect(next.drainPendingChanges).toHaveBeenCalledOnce(); + expect(next.reloadPreview).toHaveBeenCalledOnce(); + expect(next.reloadSdkSession).toHaveBeenCalledExactlyOnceWith("."); + expect(next.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null); + }); + + it("persists a queued reconnect conflict with the new project's owner", async () => { + let finishDrain = () => {}; + const pendingEdit = new Promise((resolve) => { + finishDrain = resolve; + }); + const first = { + drainPendingChanges: vi.fn(async () => { + await pendingEdit; + return { status: "clean" as const }; + }), + persistConflictSnapshot: vi.fn(async () => undefined), + reloadPreview: vi.fn(), + reloadSdkSession: vi.fn(), + }; + const { captured, rerender } = await mountCoordinator(first); + await open(); + await open(); + const conflict = new StudioFileConflictError({ + filePath: "scenes/next.html", + currentVersion: "v2", + currentContent: "external", + attemptedContent: "studio", + }); + const next = { + projectId: "project-b", + activeCompPath: "scenes/next.html", + drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })), + persistConflictSnapshot: vi.fn(async () => undefined), + reloadPreview: vi.fn(), + reloadSdkSession: vi.fn(), + }; + await rerender(next); + await open(); + await act(async () => finishDrain()); + expect(next.drainPendingChanges).toHaveBeenCalledOnce(); + expect(next.persistConflictSnapshot).toHaveBeenCalledExactlyOnceWith("project-b", conflict); + expect(first.persistConflictSnapshot).not.toHaveBeenCalled(); + expect(first.reloadPreview).not.toHaveBeenCalled(); + expect(first.reloadSdkSession).not.toHaveBeenCalled(); + expect(next.reloadPreview).not.toHaveBeenCalled(); + expect(next.reloadSdkSession).not.toHaveBeenCalled(); + expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict }); + }); + + it.each([ + { label: "the project changes", projectId: "project-b", activeCompPath: "index.html" }, + { label: "the project is left", projectId: null, activeCompPath: null }, + ])("drops queued old-scope changes when $label", async ({ projectId, activeCompPath }) => { + let finishDrain = () => {}; + const pendingEdit = new Promise((resolve) => { + finishDrain = resolve; + }); + const first = { + drainPendingChanges: vi.fn(async () => { + await pendingEdit; + return { status: "clean" as const }; + }), + reloadPreview: vi.fn(), + reloadSdkSession: vi.fn(), + }; + const { rerender } = await mountCoordinator(first); + await open(); + await open(); + await open(); + const next = { + projectId, + activeCompPath, + drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })), + reloadPreview: vi.fn(), + reloadSdkSession: vi.fn(), + }; + await rerender(next); + await act(async () => finishDrain()); + expect(first.drainPendingChanges).toHaveBeenCalledOnce(); + expect(next.drainPendingChanges).not.toHaveBeenCalled(); + expect(first.reloadPreview).not.toHaveBeenCalled(); + expect(first.reloadSdkSession).not.toHaveBeenCalled(); + expect(next.reloadPreview).not.toHaveBeenCalled(); + expect(next.reloadSdkSession).not.toHaveBeenCalled(); + }); + + it("still refreshes the tree for a queued outside change after switching compositions", async () => { + let finishDrain = () => {}; + const pendingEdit = new Promise((resolve) => { + finishDrain = resolve; + }); + const refreshFileTree = vi.fn(); + const onAcceptedPersistedFileChange = vi.fn(); + const { rerender } = await mountCoordinator({ + drainPendingChanges: vi.fn(async () => { + await pendingEdit; + return { status: "clean" as const }; + }), + refreshFileTree, + onAcceptedPersistedFileChange, + }); + await send({ path: "index.html", content: "external", version: "v2" }); + await send({ path: "scenes/old.html", content: "agent", version: "v3" }); + await rerender({ activeCompPath: "scenes/next.html" }); + await act(async () => finishDrain()); + expect(onAcceptedPersistedFileChange.mock.calls.map(([path]) => path)).toContain( + "scenes/old.html", + ); + expect(refreshFileTree).toHaveBeenCalled(); + }); + + it("ignores reconnects after leaving the active project", async () => { + const { options, rerender } = await mountCoordinator(); + await open(); + await rerender({ projectId: null }); + await open(); + expect(options.drainPendingChanges).not.toHaveBeenCalled(); + expect(options.reloadPreview).not.toHaveBeenCalled(); + }); + }); + it("drains before reloading Preview and SDK exactly once", async () => { const order: string[] = []; const { captured } = await mountCoordinator({ @@ -431,16 +872,17 @@ describe("external file change coordinator", () => { expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce(); }); - it("keeps the failed file's payload when a snapshot delete fails for another file", async () => { + it("keeps the failed draft blocked, owing both files, when deleting its snapshot fails", async () => { + const deleteConflictSnapshot = vi.fn(async () => { + throw new Error("delete failed"); + }); const { captured } = await mountCoordinator({ drainPendingChanges: vi .fn() .mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") }) .mockResolvedValueOnce({ status: "clean" as const }), getPendingCandidate: () => ({ path: "scene.html", content: "studio" }), - deleteConflictSnapshot: vi.fn(async () => { - throw new Error("delete failed"); - }), + deleteConflictSnapshot, }); await act(async () => handler?.({ path: "scene.html", content: "scene-external", version: "s1" }), @@ -450,7 +892,95 @@ describe("external file change coordinator", () => { ); expect(captured.handle?.blocked).toMatchObject({ status: "failed", - payload: { path: "scene.html", content: "scene-external", version: "s1" }, + path: "scene.html", + payload: { path: "." }, + }); + expect(deleteConflictSnapshot).toHaveBeenCalledExactlyOnceWith("project-a", "scene.html"); + }); + + it("keeps a failed draft's snapshot when a clean save was for another file", async () => { + const deleteConflictSnapshot = vi.fn(async () => undefined); + let candidate = { path: "script.js", content: "unsaved script" }; + const { captured } = await mountCoordinator({ + drainPendingChanges: vi + .fn() + .mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") }) + .mockResolvedValue({ status: "clean" as const }), + getPendingCandidate: () => candidate, + persistFailureSnapshot: vi.fn(async () => undefined), + deleteConflictSnapshot, + }); + await act(async () => handler?.({ path: "index.html", version: "v1" })); + expect(captured.handle?.blocked).toMatchObject({ status: "failed", path: "script.js" }); + candidate = { path: "style.css", content: "saved style" }; + await act(async () => handler?.({ path: "index.html", version: "v2" })); + expect(deleteConflictSnapshot).not.toHaveBeenCalled(); + }); + + it("keeps a restored draft's snapshot when an unrelated change saves cleanly", async () => { + const deleteConflictSnapshot = vi.fn(async () => undefined); + const { captured, options } = await mountCoordinator({ + recoveryFilePath: "script.js", + deleteConflictSnapshot, + loadConflictSnapshot: vi.fn(async () => ({ + kind: "failed" as const, + projectId: "project-a", + filePath: "script.js", + externalVersion: null, + externalContent: null, + studioContent: "recover me", + failureMessage: "network unavailable", + createdAt: 100, + })), + }); + await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("failed")); + await act(async () => handler?.({ path: "index.html", version: "v2" })); + expect(deleteConflictSnapshot).not.toHaveBeenCalled(); + expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith("index.html"); + }); + + it("reloads the held change's scope too when Keep Studio settles a held conflict", async () => { + const conflict = new StudioFileConflictError({ + filePath: "film.html", + currentVersion: "v2", + currentContent: "external", + attemptedContent: "studio", + }); + const { captured, options } = await mountCoordinator({ + drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })), + }); + await act(async () => handler?.({ path: "film.html", version: "v2" })); + await act(async () => + handler?.({ + path: "scenes/nested.html", + version: "n1", + affectedCompositions: ["scenes/nested.html"], + }), + ); + await act(async () => captured.handle?.keepStudioFile()); + expect(options.overwriteConflict).toHaveBeenCalledWith(conflict); + expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith("."); + expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null); + }); + + it("names the conflicting file when its conflict snapshot cannot be saved", async () => { + const conflict = new StudioFileConflictError({ + filePath: "script.js", + currentVersion: "v2", + currentContent: "external", + attemptedContent: "studio", + }); + const { captured } = await mountCoordinator({ + drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })), + persistConflictSnapshot: vi.fn(async () => { + throw new Error("storage full"); + }), + }); + await act(async () => handler?.({ path: "index.html", version: "v2" })); + expect(captured.handle?.blocked).toMatchObject({ + status: "failed", + path: "script.js", + studioContent: "studio", }); }); @@ -529,10 +1059,6 @@ describe("external file change coordinator", () => { expect(onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2); }); - // `hyperframes preview` serves file-change over SSE, where the delivery is a - // MessageEvent whose `data` is a JSON STRING. Driven through the test adapter - // because vitest defines `import.meta.hot`, so the EventSource rung is - // unreachable here, which is exactly why decoding is shared by all rungs. describe("SSE-shaped deliveries", () => { const sseDelivery = (payload: unknown) => new MessageEvent("file-change", { data: JSON.stringify(payload) }); diff --git a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts index 5026ab7363a..b1fa28e1299 100644 --- a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts +++ b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; import { - mergeFileChangeAffectedCompositions, + mergeFileChangePayloads, readFileChangeAffectedCompositions, readFileChangeAffectsPreview, readFileChangeField, @@ -125,14 +125,18 @@ function decodeFileChange(delivery: unknown): unknown { } } -/** - * The production transport. vitest defines `import.meta.hot`, so the selection - * below never reaches this rung under test; the rung itself is exported so a test - * can drive a real `MessageEvent` through the listener it registers. - */ -export function sseFileChangeChannel(onDelivery: (delivery: unknown) => void): () => void { +export function sseFileChangeChannel( + onDelivery: (delivery: unknown) => void, + onReconnect: () => void, +): () => void { const eventSource = new EventSource("/api/events"); eventSource.addEventListener("file-change", onDelivery); + // Reopening the stream does not replay changes missed during a disconnect. + let opened = false; + eventSource.addEventListener("open", () => { + if (opened) onReconnect(); + opened = true; + }); return () => eventSource.close(); } @@ -168,18 +172,19 @@ export function useExternalFileChangeCoordinator({ const generationRef = useRef(0); const mountedRef = useRef(true); const lastEventIdentityRef = useRef(null); - const blockedRef = useRef(blocked); + const blockedRef = useRef(null); const snapshotWriteTailRef = useRef>(Promise.resolve()); const drainingRef = useRef(false); const pendingPayloadRef = useRef<{ payload: unknown } | null>(null); - blockedRef.current = blocked; - // A newer blocked change replaces the held one, so it inherits the thumbnails the held one owed. + // A newer blocked change replaces the held one, so it inherits what the held one owed. const setBlocked = useCallback((next: ExternalFileChangeBlockedState | null) => { - setBlockedState((held) => + const held = blockedRef.current; + const current = next && held - ? { ...next, payload: mergeFileChangeAffectedCompositions(held.payload, next.payload) } - : next, - ); + ? { ...next, payload: mergeFileChangePayloads(held.payload, next.payload) } + : next; + blockedRef.current = current; + setBlockedState(current); }, []); useEffect(() => { @@ -190,6 +195,10 @@ export function useExternalFileChangeCoordinator({ }; }, []); + useEffect(() => { + pendingPayloadRef.current = null; + }, [projectId]); + useEffect(() => { generationRef.current += 1; setBlocked(null); @@ -240,8 +249,12 @@ export function useExternalFileChangeCoordinator({ }; }, [loadConflictSnapshot, projectId, recoveryFilePath, setBlocked]); - const reloadAcceptedGeneration = useCallback( - (path: string, affectsPreview = true) => { + // What a settled change owes: thumbnails (unless its own write's echo brings them), then reloads. + const deliverOwed = useCallback( + (owed: unknown, accept = true) => { + const path = readStudioFileChangePath(owed) ?? "."; + const affectsPreview = readFileChangeAffectsPreview(owed); + if (accept) onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed)); logReload(affectsPreview ? "reload" : "file-tree only", { path, by: "external-change coordinator", @@ -255,7 +268,7 @@ export function useExternalFileChangeCoordinator({ // a watcher event the SSE never delivered). void refreshFileTree?.(); }, - [reloadPreview, reloadSdkSession, refreshFileTree], + [onAcceptedPersistedFileChange, reloadPreview, reloadSdkSession, refreshFileTree], ); const persistSnapshotInOrder = useCallback(async (write: () => Promise) => { @@ -274,21 +287,28 @@ export function useExternalFileChangeCoordinator({ if (!path) return; const generation = ++generationRef.current; + const savingPath = getPendingCandidate?.()?.path; const result = await drainPendingChanges(); if (!mountedRef.current || generation !== generationRef.current) return; if (result.status === "clean") { const previousBlocked = blockedRef.current; - if (previousBlocked?.status === "failed" && deleteConflictSnapshot) { + // Only saving the failed draft's file retires its copy; restored drafts are never queued. + if ( + previousBlocked?.status === "failed" && + !previousBlocked.recovered && + previousBlocked.path === savingPath && + deleteConflictSnapshot + ) { try { - await deleteConflictSnapshot(projectId!, path); + await deleteConflictSnapshot(projectId!, previousBlocked.path); } catch (error) { if (mountedRef.current && generation === generationRef.current) { setBlocked({ ...previousBlocked, generation, error, - payload: mergeFileChangeAffectedCompositions(payload, previousBlocked.payload), + payload: mergeFileChangePayloads(payload, previousBlocked.payload), }); } return; @@ -296,27 +316,30 @@ export function useExternalFileChangeCoordinator({ } if (!mountedRef.current || generation !== generationRef.current) return; setBlocked(null); - const owed = previousBlocked - ? mergeFileChangeAffectedCompositions(previousBlocked.payload, payload) - : payload; - onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed)); - reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload)); + const restored = previousBlocked?.status === "failed" && previousBlocked.recovered; + deliverOwed( + previousBlocked && !restored + ? mergeFileChangePayloads(previousBlocked.payload, payload) + : payload, + ); return; } - const content = readFileChangeContent(payload); if (result.status === "failed") { + // The draft names the file to recover; the payload stays the scope to reload. const candidate = getPendingCandidate?.(); - const studioContent = candidate?.path === path ? candidate.content : null; + const failedPath = candidate?.path ?? (path === "." ? (recoveryFilePath ?? path) : path); + const studioContent = candidate?.content ?? null; + const external = failedPath === path ? payload : null; let error = result.error; if (studioContent != null && persistFailureSnapshot) { try { await persistSnapshotInOrder(() => persistFailureSnapshot( projectId!, - path, + failedPath, studioContent, - readFileChangeVersion(payload), - content, + readFileChangeVersion(external), + readFileChangeContent(external), result.error, ), ); @@ -333,7 +356,7 @@ export function useExternalFileChangeCoordinator({ setBlocked({ status: "failed", generation, - path, + path: failedPath, error, payload, studioContent, @@ -348,7 +371,7 @@ export function useExternalFileChangeCoordinator({ setBlocked({ status: "failed", generation, - path, + path: result.error.filePath, error, payload, studioContent: result.error.attemptedContent, @@ -365,14 +388,17 @@ export function useExternalFileChangeCoordinator({ projectId, deleteConflictSnapshot, getPendingCandidate, + recoveryFilePath, persistConflictSnapshot, persistFailureSnapshot, persistSnapshotInOrder, - reloadAcceptedGeneration, - onAcceptedPersistedFileChange, + deliverOwed, ], ); + const drainOnePendingRef = useRef(drainOnePending); + drainOnePendingRef.current = drainOnePending; + const startDrainLoop = useCallback(async () => { if (drainingRef.current) return; drainingRef.current = true; @@ -381,12 +407,12 @@ export function useExternalFileChangeCoordinator({ const pending = pendingPayloadRef.current; if (!pending) break; pendingPayloadRef.current = null; - await drainOnePending(pending.payload); + await drainOnePendingRef.current(pending.payload); } } finally { drainingRef.current = false; } - }, [drainOnePending]); + }, []); const processChange = useCallback( // fallow-ignore-next-line complexity @@ -439,8 +465,7 @@ export function useExternalFileChangeCoordinator({ !readFileChangeAffectsPreview(payload); if (!waitingChangeOutranksThis) { pendingPayloadRef.current = { - payload: - waiting == null ? payload : mergeFileChangeAffectedCompositions(waiting, payload), + payload: waiting == null ? payload : mergeFileChangePayloads(waiting, payload), }; } void startDrainLoop(); @@ -462,7 +487,8 @@ export function useExternalFileChangeCoordinator({ import.meta.hot.on("hf:file-change", handler); return () => import.meta.hot?.off?.("hf:file-change", handler); } - return sseFileChangeChannel(handler); + const catchUp = () => processChangeRef.current({ path: "." }); + return sseFileChangeChannel(handler, catchUp); }, []); const retry = useCallback(async () => { @@ -489,18 +515,16 @@ export function useExternalFileChangeCoordinator({ onUseExternalFile?.(path, external); await deleteConflictSnapshot?.(projectId, path); setBlocked(null); - onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(current.payload)); - reloadAcceptedGeneration(path, readFileChangeAffectsPreview(current.payload)); + deliverOwed(current.payload); }, [ deleteConflictSnapshot, setBlocked, discardPendingChanges, onUseExternalFile, - onAcceptedPersistedFileChange, projectId, readProjectFile, - reloadAcceptedGeneration, + deliverOwed, resetSaveQueues, ], ); @@ -550,7 +574,10 @@ export function useExternalFileChangeCoordinator({ resetSaveQueues?.(); await deleteConflictSnapshot?.(projectId, conflict.filePath); setBlocked(null); - reloadAcceptedGeneration(conflict.filePath); + deliverOwed( + mergeFileChangePayloads(current.payload, { path: conflict.filePath, affectsPreview: true }), + readStudioFileChangePath(current.payload) !== conflict.filePath, + ); }, [ deleteConflictSnapshot, setBlocked, @@ -558,7 +585,7 @@ export function useExternalFileChangeCoordinator({ overwriteConflict, projectId, readProjectFile, - reloadAcceptedGeneration, + deliverOwed, resetSaveQueues, ]); diff --git a/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx b/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx index e999006ca07..48f24c53fa3 100644 --- a/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx +++ b/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx @@ -13,6 +13,7 @@ vi.mock("@hyperframes/sdk", () => ({ import type { Composition } from "@hyperframes/sdk"; import { useSdkSession, type SdkSessionHandle } from "./useSdkSession"; import { usePlayerStore } from "../player/store/playerStore"; +import { notifyExternalFileReload } from "./externalFileReloadBus"; vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); @@ -173,6 +174,25 @@ describe("useSdkSession ownership", () => { await act(async () => root.unmount()); expect(published.dispose).toHaveBeenCalledOnce(); }); + + it("reopens on a project-wide external change whatever composition it holds", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => response("INDEX")), + ); + openComposition.mockImplementation(async () => fakeSession()); + const root = createRoot(document.createElement("div")); + await act(async () => root.render()); + await flushAsyncEffects(); + expect(openComposition).toHaveBeenCalledOnce(); + await act(async () => notifyExternalFileReload("film.html")); + await flushAsyncEffects(); + expect(openComposition).toHaveBeenCalledOnce(); + await act(async () => notifyExternalFileReload(".")); + await flushAsyncEffects(); + expect(openComposition).toHaveBeenCalledTimes(2); + act(() => root.unmount()); + }); }); describe("useSdkSession unreachable project", () => { diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index c32cee2b291..b6e433a130f 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -409,7 +409,10 @@ export function useSdkSession( useEffect( () => addExternalFileReloadListener((changedPath) => { - if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1); + // "." is a project-wide change (a reconnect or a replaced folder): every session reloads. + if (changedPath === "." || changedPath === activeCompPathRef.current) { + setReloadToken((token) => token + 1); + } }), [], ); diff --git a/packages/studio/vitest.config.ts b/packages/studio/vitest.config.ts new file mode 100644 index 00000000000..426e821ec78 --- /dev/null +++ b/packages/studio/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config"; + +export default mergeConfig( + viteConfig, + defineConfig({ + esbuild: { define: { "import.meta.hot": "undefined" } }, + }), +);