fix(studio): catch up the preview after missed file changes (#5126)

* fix(studio): recover previews after missed file changes

* fix(studio): keep queued reloads with the current owner

* fix(studio): keep a queued outside change when switching compositions

* test(cli): the project watcher closes its parent folder's watch too

* fix(studio): keep the project-folder change out of the shared signature check

* fix(studio): keep the edited file's draft and reload every session after a reconnect

A reconnect or replaced folder is a project-wide change: every open SDK session reloads, a failed save is recorded against the file the draft belongs to, and two queued changes to different files merge into one project-wide reload.

* fix(studio): refresh the preview cache when the .hyperframes folder is replaced

* fix(cli): report the files of a folder moved into the project

Replacing a populated folder such as .hyperframes/ now reaches the signature cache through its files, so the shared signature rule stays as on main and Studio's own writes inside .hyperframes/ still invalidate nothing.

* refactor(studio): name the merged file-change payload and share the test sender

* fix(studio): keep a failed draft's recovery copy until that file itself saves

* style(studio): rewrap the merged payload line after the rename

* fix(studio): deliver a held change's full reload when its block clears

A clean save, Use external and Keep Studio now all deliver the merged scope a blocked change owes, and a moved-in .hyperframes folder reports its manifests so a dropped one still clears the cached signature.

* fix(studio): keep restored drafts out of the reload scope and clear the lint gates

* fix(studio): retain held reload scope before React renders
This commit is contained in:
Miguel Ángel
2026-10-07 07:32:16 +00:00
committed by GitHub
parent 4fcad1ed74
commit dbb8222c8d
14 changed files with 950 additions and 93 deletions
+67 -7
View File
@@ -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");
@@ -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<typeof createProjectWatcher> | null = null;
+41 -21
View File
@@ -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))
@@ -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<Uint8Array> | 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) => `<html><head></head><body>
<div data-composition-id="main" data-width="320" data-height="180">
<p>${label}</p><div data-composition-id="intro" data-composition-src="scenes/intro.html" data-start="0" data-duration="1"></div>
</div></body></html>`;
const scene = (
text: string,
) => `<template><div data-composition-id="intro" data-width="320" data-height="180">
<h1>${text}</h1></div></template>`;
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"),
`<html><body><div data-composition-id="main" data-width="320" data-height="180"></div></body></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);
});
},
);
+1 -1
View File
@@ -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;
}
});
+5 -1
View File
@@ -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";
@@ -10,7 +10,7 @@ export {
} from "./manualEditsTypes";
export {
mergeFileChangeAffectedCompositions,
mergeFileChangePayloads,
readFileChangeAffectedCompositions,
readFileChangeAffectsPreview,
readFileChangeField,
@@ -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,
};
}
@@ -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<string, (event: unknown) => 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"]);
@@ -41,7 +41,11 @@ async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
return null;
}
await act(async () => root.render(<Probe />));
return { captured, options };
const rerender = async (next: Partial<CoordinatorOptions>) => {
Object.assign(options, next);
await act(async () => root.render(<Probe />));
};
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<typeof vi.fn>;
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<void>((resolve) => {
finishAcceptance = resolve;
});
const blockedAtSecondDrain: Array<ExternalFileChangeCoordinatorHandle["blocked"]> = [];
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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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) });
@@ -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<string | null>(null);
const blockedRef = useRef(blocked);
const blockedRef = useRef<ExternalFileChangeBlockedState | null>(null);
const snapshotWriteTailRef = useRef<Promise<void>>(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<void>) => {
@@ -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,
]);
@@ -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(<Probe projectId="project-a" />));
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", () => {
+4 -1
View File
@@ -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);
}
}),
[],
);
+9
View File
@@ -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" } },
}),
);