Skip to content

Commit dbb8222

Browse files
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
1 parent 4fcad1e commit dbb8222

14 files changed

Lines changed: 950 additions & 93 deletions

‎packages/cli/src/server/fileWatcher.fs.test.ts‎

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@ describe("createProjectWatcher on a real directory", () => {
1414
rmSync(dir, { recursive: true, force: true });
1515
});
1616

17-
const watchProject = async () => {
17+
const watchProject = (projectDir = dir) => {
1818
const seen: string[] = [];
19-
watcher = createProjectWatcher(dir);
19+
watcher = createProjectWatcher(projectDir);
2020
watcher.addListener((path) => seen.push(path));
21-
await new Promise((resolve) => setTimeout(resolve, 100));
2221
return seen;
2322
};
2423
const expectReported = async (seen: string[], path: string) => {
@@ -35,7 +34,7 @@ describe("createProjectWatcher on a real directory", () => {
3534
mkdirSync(join(dir, "compositions"));
3635
writeFileSync(join(dir, "index.html"), "v0");
3736
writeFileSync(join(dir, "compositions", "scene.html"), "v0");
38-
const seen = await watchProject();
37+
const seen = watchProject();
3938

4039
for (const path of ["index.html", join("compositions", "scene.html")]) {
4140
replaceByRename(join(dir, path), "stamped");
@@ -47,10 +46,71 @@ describe("createProjectWatcher on a real directory", () => {
4746
}
4847
});
4948

49+
it.runIf(process.platform === "linux")(
50+
"follows a project folder replaced by rename",
51+
async () => {
52+
dir = mkdtempSync(join(tmpdir(), "hf-watch-root-"));
53+
const project = join(dir, "project");
54+
const replacement = join(dir, "replacement");
55+
mkdirSync(project);
56+
mkdirSync(join(replacement, "scenes"), { recursive: true });
57+
writeFileSync(join(project, "index.html"), "before");
58+
writeFileSync(join(replacement, "index.html"), "after");
59+
writeFileSync(join(replacement, "scenes", "intro.html"), "new scene");
60+
const seen = watchProject(project);
61+
62+
renameSync(project, join(dir, "previous"));
63+
renameSync(replacement, project);
64+
await expectReported(seen, ".");
65+
writeFileSync(join(project, "index.html"), "edited root");
66+
await expectReported(seen, "index.html");
67+
writeFileSync(join(project, "scenes", "intro.html"), "edited scene");
68+
await expectReported(seen, join("scenes", "intro.html"));
69+
},
70+
);
71+
72+
it.runIf(process.platform === "linux")(
73+
"re-arms after the project path is absent between replacements",
74+
async () => {
75+
dir = mkdtempSync(join(tmpdir(), "hf-watch-gap-"));
76+
const project = join(dir, "project");
77+
mkdirSync(project);
78+
writeFileSync(join(project, "index.html"), "before");
79+
const seen = watchProject(project);
80+
81+
renameSync(project, join(dir, "previous"));
82+
await expectReported(seen, ".");
83+
mkdirSync(project);
84+
writeFileSync(join(project, "index.html"), "after");
85+
await expectReported(seen, ".");
86+
writeFileSync(join(project, "index.html"), "later edit");
87+
await expectReported(seen, "index.html");
88+
},
89+
);
90+
91+
it.runIf(process.platform === "linux")(
92+
"follows a replaced subdirectory's new inode",
93+
async () => {
94+
dir = mkdtempSync(join(tmpdir(), "hf-watch-subdir-"));
95+
const scenes = join(dir, "scenes");
96+
mkdirSync(scenes);
97+
mkdirSync(join(dir, "replacement"));
98+
writeFileSync(join(scenes, "intro.html"), "before");
99+
writeFileSync(join(dir, "replacement", "intro.html"), "after");
100+
const seen = watchProject();
101+
102+
renameSync(scenes, join(dir, "previous"));
103+
renameSync(join(dir, "replacement"), scenes);
104+
await expectReported(seen, "scenes");
105+
writeFileSync(join(scenes, "intro.html"), "later edit");
106+
await expectReported(seen, join("scenes", "intro.html"));
107+
},
108+
);
109+
50110
it("reports Studio's manifest writes inside .hyperframes", async () => {
51111
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
52112
mkdirSync(join(dir, ".hyperframes"));
53-
const seen = await watchProject();
113+
const seen = watchProject();
54114

55115
writeFileSync(join(dir, ".hyperframes", "studio-motion.json"), "{}");
56116
await expectReported(seen, join(".hyperframes", "studio-motion.json"));
@@ -60,7 +120,7 @@ describe("createProjectWatcher on a real directory", () => {
60120
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
61121
mkdirSync(join(dir, "scene"));
62122
mkdirSync(join(dir, "scenes"));
63-
const seen = await watchProject();
123+
const seen = watchProject();
64124

65125
rmSync(join(dir, "scene"), { recursive: true });
66126
await expectReported(seen, "scene");
@@ -70,7 +130,7 @@ describe("createProjectWatcher on a real directory", () => {
70130

71131
it("reports files in a directory created after it started", async () => {
72132
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
73-
const seen = await watchProject();
133+
const seen = watchProject();
74134

75135
mkdirSync(join(dir, "scenes"));
76136
await expectReported(seen, "scenes");

‎packages/cli/src/server/fileWatcher.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,29 @@ describe("createProjectWatcher", () => {
9797
},
9898
);
9999

100+
it.runIf(process.platform === "linux")("closes every watch it opened, its parent's too", () => {
101+
const projectWatcher = createProjectWatcher("/fake/project/dir");
102+
const opened = vi.mocked(watch).mock.calls.map(([path]) => path);
103+
expect(opened).toContain("/fake/project");
104+
projectWatcher.close();
105+
expect(mockWatcher.close).toHaveBeenCalledTimes(opened.length);
106+
});
107+
108+
it.runIf(process.platform === "linux")(
109+
"keeps reporting project files when its parent cannot be watched",
110+
() => {
111+
vi.useFakeTimers();
112+
fakeDirs.unwatchable = "/fake/project";
113+
const projectWatcher = createProjectWatcher("/fake/project/dir");
114+
const listener = vi.fn();
115+
projectWatcher.addListener(listener);
116+
mockWatcher.emit("change", "change", "index.html");
117+
vi.advanceTimersByTime(30);
118+
expect(listener).toHaveBeenCalledExactlyOnceWith("index.html");
119+
projectWatcher.close();
120+
},
121+
);
122+
100123
it("degrades to no live reload when the project root cannot be watched", () => {
101124
fakeDirs.unwatchable = "/fake/project/dir";
102125
let projectWatcher: ReturnType<typeof createProjectWatcher> | null = null;

‎packages/cli/src/server/fileWatcher.ts‎

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { lstatSync, readdirSync, watch, type FSWatcher } from "node:fs";
2-
import { join, relative, sep } from "node:path";
1+
import { lstatSync, readdirSync, watch, type Dirent, type FSWatcher } from "node:fs";
2+
import { basename, dirname, join, relative, sep } from "node:path";
33
import { isAtomicTempPath } from "@hyperframes/core/atomic-file";
4-
import { affectsProjectSignature } from "@hyperframes/studio-server";
4+
import {
5+
affectsProjectSignature,
6+
STUDIO_SIGNATURE_MANIFEST_PATHS,
7+
} from "@hyperframes/studio-server";
58

69
export type FileChangeListener = (relativePath: string) => void;
710

@@ -70,43 +73,63 @@ function watchProjectTree(
7073
}
7174
}
7275
};
73-
const watchDirectory = (dir: string) => {
74-
if (directories.has(dir)) return;
76+
const watchDirectory = (dir: string, movedIn = false) => {
77+
if (directories.has(dir)) unwatch(dir);
7578
let watcher: FSWatcher;
7679
try {
7780
watcher = watch(dir, { persistent: true }, (event, name) => {
7881
if (!name) return;
7982
const path = join(dir, name.toString());
8083
onChange(relative(projectDir, path));
8184
if (event !== "rename") return;
82-
if (isDirectory(path)) descend(path);
85+
if (isDirectory(path)) descend(path, true);
8386
else unwatch(path);
8487
});
85-
} catch (error) {
86-
// One unwatchable subdirectory (EACCES, inotify limit) must not cost the rest of the tree.
87-
if (dir === projectDir) throw error;
88+
} catch {
89+
// A directory can vanish during replacement; its parent reports its return.
8890
return;
8991
}
9092
watcher.on("error", () => unwatch(dir));
9193
directories.set(dir, watcher);
92-
let entries: string[] = [];
94+
walkChildren(dir, movedIn);
95+
};
96+
const walkChildren = (dir: string, movedIn: boolean) => {
97+
let entries: Dirent[] = [];
9398
try {
94-
entries = readdirSync(dir, { withFileTypes: true })
95-
.filter((entry) => entry.isDirectory())
96-
.map((entry) => join(dir, entry.name));
99+
entries = readdirSync(dir, { withFileTypes: true });
97100
} catch {
98101
// Gone before we could list it; its parent reports the removal.
99102
}
100-
for (const child of entries) descend(child);
103+
for (const entry of entries) {
104+
const child = join(dir, entry.name);
105+
// A folder moved in brings files that never get an event of their own, and drops others.
106+
if (entry.isDirectory()) descend(child, movedIn);
107+
else if (movedIn) onChange(relative(projectDir, child));
108+
}
109+
if (movedIn && relative(projectDir, dir) === ".hyperframes") {
110+
for (const manifest of STUDIO_SIGNATURE_MANIFEST_PATHS) onChange(manifest);
111+
}
101112
};
102113
// `.hyperframes/` itself holds the two manifests the signature reads; nothing below it matters.
103-
const descend = (dir: string) => {
114+
const descend = (dir: string, movedIn = false) => {
104115
const rel = relative(projectDir, dir);
105-
if (shouldWatchProjectFile(rel) || rel === ".hyperframes") watchDirectory(dir);
116+
if (shouldWatchProjectFile(rel) || rel === ".hyperframes") watchDirectory(dir, movedIn);
106117
};
107118

119+
let parent: FSWatcher | null = null;
120+
try {
121+
parent = watch(dirname(projectDir), { persistent: true }, (event, name) => {
122+
if (event !== "rename" || name?.toString() !== basename(projectDir)) return;
123+
watchDirectory(projectDir);
124+
onChange(".");
125+
});
126+
parent.on("error", () => parent?.close());
127+
} catch {
128+
// The project can remain watchable even when its parent is not.
129+
}
108130
watchDirectory(projectDir);
109131
return () => {
132+
parent?.close();
110133
for (const watcher of directories.values()) watcher.close();
111134
directories.clear();
112135
};
@@ -121,11 +144,8 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
121144

122145
try {
123146
closeTree = watchProjectTree(projectDir, (relativePath) => {
124-
// The reload filter excludes all of `.hyperframes/`, but two files in
125-
// there feed the preview signature and Studio writes one of them at
126-
// runtime — dropping those at ingest left the CLI server's ETag stale
127-
// until restart. Admit them here and let the reload listener re-apply
128-
// its own filter, so what triggers a browser reload is unchanged.
147+
// Studio's two manifests affect the signature despite the .hyperframes exclusion.
148+
// Admit them here; the reload listener still applies its own filter.
129149
if (
130150
!shouldWatchProjectFile(relativePath) &&
131151
!affectsProjectSignature(projectDir, join(projectDir, relativePath))
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { afterEach, expect, it, vi } from "vitest";
4+
import { compositionsAffectedBy } from "@hyperframes/studio-server";
5+
import { createStudioServer, type StudioServer } from "./studioServer.js";
6+
import { cleanupStudioServerRoot, makeStudioServerRoot } from "./studioServerTestFixture.js";
7+
8+
let root: string;
9+
let server: StudioServer | undefined;
10+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
11+
12+
afterEach(async () => {
13+
await reader?.cancel();
14+
if (server) cleanupStudioServerRoot(server, root);
15+
});
16+
17+
it.runIf(process.platform === "linux")(
18+
"refreshes every composition and cached preview when its project directory is replaced",
19+
async () => {
20+
const fixture = makeStudioServerRoot("hf-preview-replace-");
21+
root = fixture.root;
22+
const { projectDir } = fixture;
23+
const rootHtml = (label: string) => `<html><head></head><body>
24+
<div data-composition-id="main" data-width="320" data-height="180">
25+
<p>${label}</p><div data-composition-id="intro" data-composition-src="scenes/intro.html" data-start="0" data-duration="1"></div>
26+
</div></body></html>`;
27+
const scene = (
28+
text: string,
29+
) => `<template><div data-composition-id="intro" data-width="320" data-height="180">
30+
<h1>${text}</h1></div></template>`;
31+
mkdirSync(join(projectDir, "scenes"));
32+
writeFileSync(join(projectDir, "index.html"), rootHtml("Initial"));
33+
writeFileSync(join(projectDir, "scenes/intro.html"), scene("Original"));
34+
expect(compositionsAffectedBy(projectDir, "index.html")).toBeNull();
35+
writeFileSync(join(projectDir, "index.html"), rootHtml("Unchanged root"));
36+
server = createStudioServer({ projectDir, projectName: "film" });
37+
expect(compositionsAffectedBy(projectDir, "index.html")).toEqual(["index.html"]);
38+
const app = server.app;
39+
const endpoint = "/api/projects/film/preview/comp/scenes/intro.html";
40+
const before = await app.request(endpoint);
41+
expect(before.status).toBe(200);
42+
expect(await before.text()).toContain("Original");
43+
const beforeTag = before.headers.get("etag");
44+
expect(beforeTag).toBeTruthy();
45+
46+
const response = await app.request("/api/events");
47+
reader = response.body!.getReader();
48+
const deliveries: string[] = [];
49+
const consume = async () => {
50+
while (true) {
51+
const next = await reader!.read();
52+
if (next.done) return;
53+
deliveries.push(new TextDecoder().decode(next.value));
54+
}
55+
};
56+
const consuming = consume();
57+
try {
58+
const replacement = join(root, "replacement");
59+
mkdirSync(join(replacement, "scenes"), { recursive: true });
60+
writeFileSync(join(replacement, "index.html"), readFileSync(join(projectDir, "index.html")));
61+
writeFileSync(join(replacement, "scenes/intro.html"), scene("Replacement"));
62+
renameSync(projectDir, join(root, "previous"));
63+
renameSync(replacement, projectDir);
64+
65+
await vi.waitFor(() => {
66+
const event = deliveries
67+
.join("")
68+
.split("\n")
69+
.find((line) => line.startsWith("data:") && line.includes('"path":"."'));
70+
expect(event).toBeDefined();
71+
expect(JSON.parse(event!.slice(5))).toMatchObject({
72+
path: ".",
73+
projectId: "film",
74+
affectsPreview: true,
75+
affectedCompositions: null,
76+
});
77+
});
78+
const after = await app.request(endpoint, { headers: { "If-None-Match": beforeTag! } });
79+
expect(after.status).toBe(200);
80+
expect(after.headers.get("etag")).not.toBe(beforeTag);
81+
expect(await after.text()).toContain("Replacement");
82+
83+
writeFileSync(join(projectDir, "scenes/intro.html"), scene("Later edit"));
84+
await vi.waitFor(async () => {
85+
const later = await app.request(endpoint);
86+
expect(later.status).toBe(200);
87+
expect(await later.text()).toContain("Later edit");
88+
});
89+
} finally {
90+
await reader.cancel();
91+
await consuming;
92+
}
93+
},
94+
);
95+
96+
it.runIf(process.platform === "linux").each([
97+
{ kept: "a changed manifest", file: "studio-motion.json", content: '{"intro":{"opacity":0.5}}' },
98+
{ kept: "no manifest", file: "arbitrary.txt", content: "not a manifest" },
99+
])(
100+
"refreshes the cached preview when a populated .hyperframes folder is replaced by one with $kept",
101+
async ({ file, content }) => {
102+
const fixture = makeStudioServerRoot("hf-preview-replace-manifests-");
103+
root = fixture.root;
104+
const { projectDir } = fixture;
105+
writeFileSync(
106+
join(projectDir, "index.html"),
107+
`<html><body><div data-composition-id="main" data-width="320" data-height="180"></div></body></html>`,
108+
);
109+
mkdirSync(join(projectDir, ".hyperframes"));
110+
writeFileSync(join(projectDir, ".hyperframes/studio-motion.json"), "{}");
111+
server = createStudioServer({ projectDir, projectName: "film" });
112+
const endpoint = "/api/projects/film/preview/comp/index.html";
113+
const before = await server.app.request(endpoint);
114+
expect(before.status).toBe(200);
115+
const beforeTag = before.headers.get("etag");
116+
expect(beforeTag).toBeTruthy();
117+
118+
const replacement = join(root, "next-manifests");
119+
mkdirSync(replacement);
120+
writeFileSync(join(replacement, file), content);
121+
renameSync(join(projectDir, ".hyperframes"), join(root, "previous-manifests"));
122+
renameSync(replacement, join(projectDir, ".hyperframes"));
123+
124+
await vi.waitFor(async () => {
125+
const after = await server!.app.request(endpoint, {
126+
headers: { "If-None-Match": beforeTag! },
127+
});
128+
expect(after.status).toBe(200);
129+
expect(after.headers.get("etag")).not.toBe(beforeTag);
130+
});
131+
},
132+
);

‎packages/cli/src/server/studioServer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
425425
const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
426426
let cachedProjectSignature: string | null = null;
427427
watcher.addListener((changedPath) => {
428-
if (affectsProjectSignature(projectDir, join(projectDir, changedPath))) {
428+
if (changedPath === "." || affectsProjectSignature(projectDir, join(projectDir, changedPath))) {
429429
cachedProjectSignature = null;
430430
}
431431
});

0 commit comments

Comments
 (0)