fix(studio): avoid preview reloads for temporary and excluded files (#4553)

* fix(studio): avoid preview reloads for temporary and excluded files

* docs: capture public preview reload reproduction

* fix(studio-server): normalize watch paths in linear time

* fix(studio): register dynamically opened preview projects
This commit is contained in:
James Russo
2026-09-27 02:23:25 +00:00
committed by GitHub
parent cc439b0e59
commit 31c00d2f0e
17 changed files with 363 additions and 15 deletions
Binary file not shown.
Binary file not shown.
+14
View File
@@ -79,6 +79,20 @@
}
}
}
},
"preview": {
"type": "object",
"additionalProperties": false,
"properties": {
"watchIgnore": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "Project-relative files or directories that should not trigger preview reloads (literal paths, not glob patterns). Directory entries include their descendants. Does not exclude files from rendering or serving. hyperframes.json always triggers a reload."
}
}
}
}
}
+25
View File
@@ -57,3 +57,28 @@ For lower-level failures, continue to [general troubleshooting](/guides/troubles
- [Work with source and an agent](/studio/source)
- [Finish, render, and share](/guides/export-and-share)
- [Diagnose a project or CLI problem](/guides/troubleshooting)
## Preview keeps restarting while tools write reports
Preview watches project files so source and asset edits appear automatically.
If an agent or check command writes notes and reports inside your project, exclude
those outputs from live reload in `hyperframes.json`:
```json
{
"preview": {
"watchIgnore": ["docs", "references/check-results.json"]
}
}
```
Entries are literal project-relative file or directory paths, not globs. A directory
entry includes its descendants, but not similarly named siblings. Only exclude
files the composition does not use: ignored files remain available to the server
and renderer, but edits to them will not automatically reload the player or its
thumbnails. Changes to this setting take effect without restarting the server;
`hyperframes.json` itself always triggers a reload.
Atomic-save temporary files (`.tmp`, `.tmp.<pid>.<hex>`, `.swp`, `.swo`, and trailing
`~`) do not trigger preview reloads. Renaming them to the final source filename
still does.
@@ -671,6 +671,27 @@ describe("Studio file-change SSE", () => {
const encodedVersion = (content: string): string =>
fileContentVersion(content).replaceAll('"', '\\"');
it("does not deliver report or temporary-file changes, but delivers the final source save", async () => {
const projectDir = tmpProject();
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({ preview: { watchIgnore: ["docs"] } }),
);
server = createStudioServer({ projectDir });
const streams = await subscribe(2);
mockWatcher.emit("change", "change", "docs/report.json");
mockWatcher.emit("change", "rename", "index.html.tmp.97896.1d3e1712ad2c");
await new Promise((resolve) => setTimeout(resolve, 60));
writeFileSync(join(projectDir, "index.html"), "<html>after</html>");
mockWatcher.emit("change", "rename", "index.html");
for (const payload of await Promise.all(streams.map(nextEvent))) {
expect(payload).toContain('"path":"index.html"');
expect(payload).not.toContain("report.json");
expect(payload).not.toContain(".tmp");
}
});
it("labels a Studio write for every open subscriber, not just the first", async () => {
const projectDir = tmpProject();
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
+4 -1
View File
@@ -45,6 +45,7 @@ import {
getMimeType,
affectsProjectSignature,
compositionsAffectedBy,
shouldReloadPreview,
type PreviewApiAdapter,
PREVIEW_BUNDLE_OPTIONS,
createPreviewDocumentStore,
@@ -896,7 +897,9 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// Re-applied here because the watcher now also emits the signature
// manifest files, which must not trigger a browser reload.
const wrappedListener = (changedPath: string) => {
if (shouldWatchProjectFile(changedPath)) listener(changedPath);
if (shouldWatchProjectFile(changedPath) && shouldReloadPreview(projectDir, changedPath)) {
listener(changedPath);
}
};
watcher.addListener(wrappedListener);
stream.onAbort(() => watcher.removeListener(wrappedListener));
@@ -50,6 +50,23 @@ describe("projectConfig", () => {
});
});
it("preserves preview watch exclusions through config writes and authoring updates", () => {
const dir = tmp();
try {
writeProjectConfig(dir, {
...DEFAULT_PROJECT_CONFIG,
preview: { watchIgnore: ["./docs/", "reports/check.json"] },
});
expect(readProjectConfig(dir)?.preview).toEqual({
watchIgnore: ["docs", "reports/check.json"],
});
seedProjectAuthoringSkill(dir, "music-to-video");
expect(readProjectConfig(dir)?.preview?.watchIgnore).toEqual(["docs", "reports/check.json"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
describe("normalizeConfig", () => {
it("fills in defaults for missing fields", () => {
const result = normalizeConfig({ registry: "https://alt.example.com" });
+5
View File
@@ -7,6 +7,7 @@
* point at custom registries or reshape their project layout.
*/
import { normalizePreviewWatchIgnore } from "@hyperframes/studio-server";
import { readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { DEFAULT_REGISTRY_URL } from "../registry/index.js";
@@ -59,6 +60,7 @@ export interface ProjectConfig {
paths: ProjectConfigPaths;
/** Media handling options (e.g. auto-proxying of browser-hostile codecs). */
media?: ProjectConfigMedia;
preview?: { watchIgnore?: string[] };
/**
* Owning authoring-workflow skill slug (e.g. "product-launch-video"). Stamped
* by `hyperframes init --skill` or seeded from the first `hyperframes render
@@ -151,6 +153,9 @@ export function normalizeConfig(partial: Partial<ProjectConfig>): ProjectConfig
? partial.media.autoProxy
: DEFAULT_PROJECT_CONFIG.media?.autoProxy,
},
preview: partial.preview
? { watchIgnore: normalizePreviewWatchIgnore(partial.preview.watchIgnore) }
: undefined,
// Slug-gate on read so a hand-edited or corrupt value never reaches the
// telemetry stream; an invalid slug simply drops the attribution.
authoringSkill: normalizeSkillSlug(partial.authoringSkill),
@@ -43,6 +43,7 @@ const EVERY_WRITTEN_KEY: Required<ProjectConfig> = {
registry: "https://example.test/registry",
paths: DEFAULT_PROJECT_CONFIG.paths,
media: { autoProxy: true },
preview: { watchIgnore: ["docs", "references/check-results.json"] },
authoringSkill: "product-launch-video",
registryItems: [EVERY_RECORD_FIELD],
};
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { normalizePreviewWatchIgnore, shouldReloadPreview } from "./previewWatchIgnore";
const directories: string[] = [];
function project(config: unknown = {}): string {
const dir = mkdtempSync(join(tmpdir(), "hf-watch-ignore-"));
directories.push(dir);
writeFileSync(join(dir, "hyperframes.json"), JSON.stringify(config));
return dir;
}
afterEach(() => {
for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe("preview reload filtering", () => {
it("defaults to reloading arbitrary assets, data, and source files", () => {
const dir = project();
for (const path of [
"index.html",
"assets/photo.jpg",
"data.json",
"docs/data.md",
"scene.tsx",
]) {
expect(shouldReloadPreview(dir, path)).toBe(true);
}
});
it("ignores literal paths and directory descendants without matching siblings", () => {
const dir = project({ preview: { watchIgnore: ["./docs/", "reports\\checks.json"] } });
for (const path of ["docs", "docs/a.json", "docs\\a.md", "reports/checks.json"]) {
expect(shouldReloadPreview(dir, path)).toBe(false);
}
for (const path of ["docs-other/a.json", "reports/other.json", "index.html"]) {
expect(shouldReloadPreview(dir, path)).toBe(true);
}
expect(shouldReloadPreview(dir, join(dir, "docs", "report.json"))).toBe(false);
});
it("always reloads config and reads changed settings without restarting", () => {
const dir = project({ preview: { watchIgnore: ["docs", "hyperframes.json"] } });
expect(shouldReloadPreview(dir, "hyperframes.json")).toBe(true);
expect(shouldReloadPreview(dir, "docs/report.json")).toBe(false);
writeFileSync(join(dir, "hyperframes.json"), "{}");
expect(shouldReloadPreview(dir, "docs/report.json")).toBe(true);
});
it("ignores atomic-save artifacts while retaining the final source and ordinary names", () => {
const dir = project();
for (const path of [
"index.html.tmp",
"docs/edit.md.tmp.97896.1d3e1712ad2c",
".index.html.swp",
"index.html.swo",
"index.html~",
]) {
expect(shouldReloadPreview(dir, path)).toBe(false);
}
for (const path of ["index.html", "templates/a.html", "photo.tmp.jpg"]) {
expect(shouldReloadPreview(dir, path)).toBe(true);
}
});
it("fails open on malformed or missing configuration", () => {
for (const config of [null, [], { preview: null }, { preview: { watchIgnore: "docs" } }]) {
expect(shouldReloadPreview(project(config), "docs/report.json")).toBe(true);
}
const dir = project();
writeFileSync(join(dir, "hyperframes.json"), "{");
expect(shouldReloadPreview(dir, "index.html")).toBe(true);
rmSync(join(dir, "hyperframes.json"));
expect(shouldReloadPreview(dir, "index.html")).toBe(true);
});
it("handles long runs of separators in untrusted settings", () => {
const separators = "/".repeat(100_000);
expect(normalizePreviewWatchIgnore([`docs${separators}`])).toEqual(["docs"]);
expect(normalizePreviewWatchIgnore([`docs${separators}report.json`])).toEqual([]);
});
it("rejects exclusions that could hide the whole project or escape its root", () => {
expect(
normalizePreviewWatchIgnore([
"",
".",
"./",
"..",
"../a",
"/tmp",
"C:\\tmp",
"a/../b",
"**",
null,
5,
"docs",
]),
).toEqual(["docs"]);
const dir = project();
expect(shouldReloadPreview(dir, dir)).toBe(false);
expect(shouldReloadPreview(dir, join(dir, ".."))).toBe(false);
expect(shouldReloadPreview(dir, join(dir, "..", "other.html"))).toBe(false);
});
});
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
const CONFIG_FILE = "hyperframes.json";
export function normalizePreviewWatchIgnore(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry: unknown) => {
if (typeof entry !== "string") return [];
const normalized = entry.replace(/\\/g, "/").replace(/^\.\//, "");
let end = normalized.length;
while (end > 0 && normalized[end - 1] === "/") end -= 1;
const path = normalized.slice(0, end);
if (
!path ||
path.startsWith("/") ||
/[:*?[\]]/.test(path) ||
path.split("/").some((part) => !part || part === "." || part === "..")
) {
return [];
}
return [path];
});
}
function readIgnoredPaths(projectDir: string): string[] {
try {
const config: unknown = JSON.parse(readFileSync(resolve(projectDir, CONFIG_FILE), "utf8"));
if (!config || typeof config !== "object" || !("preview" in config)) return [];
const preview = config.preview;
if (!preview || typeof preview !== "object" || !("watchIgnore" in preview)) return [];
return normalizePreviewWatchIgnore(preview.watchIgnore);
} catch {
return [];
}
}
export function shouldReloadPreview(projectDir: string, changedPath: string): boolean {
const path = relative(resolve(projectDir), resolve(projectDir, changedPath)).replace(/\\/g, "/");
if (!path || path === ".." || path.startsWith("../") || isAbsolute(path)) return false;
if (path === CONFIG_FILE) return true;
const name = path.split("/").at(-1) ?? "";
if (/\.tmp(?:\.\d+\.[a-f\d]+)?$/i.test(name) || /\.(?:swp|swo)$|~$/.test(name)) return false;
return !readIgnoredPaths(projectDir).some(
(ignored) => path === ignored || path.startsWith(`${ignored}/`),
);
}
+1
View File
@@ -1,3 +1,4 @@
export { shouldReloadPreview, normalizePreviewWatchIgnore } from "./helpers/previewWatchIgnore.js";
export { createStudioApi } from "./createStudioApi.js";
export { createProjectSignature, affectsProjectSignature } from "./helpers/projectSignature.js";
export { compositionsAffectedBy } from "./helpers/compositionInputs.js";
@@ -17,6 +17,8 @@ import { createStudioApi, type ProjectHistory } from "@hyperframes/studio-server
import type { ViteDevServer } from "vite";
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
import { previewChangeOwner } from "./vite.preview-watch";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
@@ -175,3 +177,35 @@ describe("Vite project resolution boundary", () => {
expect(adapter.resolveProject("alias")).toBeNull();
});
});
describe("dynamic preview ownership", () => {
it.each([false, true])("registers a project opened after startup (symlink: %s)", (linked) => {
const { root, data, sessions } = fixture();
const owners = new Map<string, string>();
const watched: string[] = [];
const cache = createProjectSignatureCache({
compute: () => "signature",
watch: (dir) => {
// Ownership must exist before newly watched files can emit events.
expect(previewChangeOwner(owners, join(dir, "index.html"))).toBe("new-project");
watched.push(dir);
},
});
const adapter = createViteAdapter(data, {} as ViteDevServer, cache, {
onResolveProject: (project) => owners.set(project.dir, project.id),
});
expect(adapter.resolveProject("new-project")).toBeNull();
expect(owners.size).toBe(0);
const dir = linked ? join(root, "different-target-name") : join(data, "new-project");
mkdirSync(dir);
if (linked) symlinkSync(dir, join(data, "new-project"), "junction");
writeFileSync(join(dir, "index.html"), "before");
writeFileSync(join(sessions, "alias.json"), JSON.stringify({ projectId: "new-project" }));
const project = adapter.resolveProject(linked ? "alias" : "new-project")!;
cache.get(project.dir);
writeFileSync(join(dir, "index.html"), "after");
expect(watched).toEqual([realpathSync(dir)]);
expect(previewChangeOwner(owners, join(realpathSync(dir), "index.html"))).toBe("new-project");
expect(previewChangeOwner(owners, join(realpathSync(dir), "index.html.tmp"))).toBeNull();
});
});
+12 -3
View File
@@ -107,7 +107,12 @@ export function createViteAdapter(
{
historyRoot = DEFAULT_HISTORY_ROOT,
openHistory = openProjectHistory,
}: { historyRoot?: string; openHistory?: typeof openProjectHistory } = {},
onResolveProject,
}: {
historyRoot?: string;
openHistory?: typeof openProjectHistory;
onResolveProject?: (project: ResolvedProject) => void;
} = {},
): StudioApiAdapter {
const histories = historyCache((projectDir) =>
openHistory({ projectDir, historyRoot }).catch((error: unknown) => {
@@ -240,11 +245,13 @@ export function createViteAdapter(
projectDir = resolve(dataDir, session.projectId);
if (!isPathWithin(dataDir, projectDir)) return null;
if (existsSync(projectDir)) {
return {
const project = {
id: session.projectId,
dir: realpathSync(projectDir),
title: session.title,
};
onResolveProject?.(project);
return project;
}
}
} catch {
@@ -253,7 +260,9 @@ export function createViteAdapter(
}
return null;
}
return { id, dir: realpathSync(projectDir) };
const project = { id, dir: realpathSync(projectDir) };
onResolveProject?.(project);
return project;
},
async bundle(dir, options) {
+14 -11
View File
@@ -9,13 +9,14 @@ import {
lstatSync,
realpathSync,
} from "node:fs";
import { join, relative, resolve, sep } from "node:path";
import { join, resolve } from "node:path";
import { readNodeRequestBody } from "./vite.request-body.js";
import { watch } from "chokidar";
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
import { previewConfigPayload } from "./vite.preview-config";
import { loadStudioServerDevModule } from "./vite.studio-server-module";
import type { openProjectHistory } from "@hyperframes/studio-server";
import { previewChangeOwner } from "./vite.preview-watch";
async function loadRuntimeSourceForDev(
server: import("vite").ViteDevServer,
@@ -102,12 +103,15 @@ function devProjectApi(): Plugin {
// ignore them (see `server.watch.ignored`), because it answers an html
// change with a full page reload; this one only announces the change and
// lets Studio decide what to do with it.
const realProjectPaths: string[] = [];
const watchedProjects = new Map<string, string>();
try {
for (const entry of readdirSync(dataDir, { withFileTypes: true })) {
const full = join(dataDir, entry.name);
try {
realProjectPaths.push(lstatSync(full).isSymbolicLink() ? realpathSync(full) : full);
watchedProjects.set(
lstatSync(full).isSymbolicLink() ? realpathSync(full) : full,
entry.name,
);
} catch {
/* skip broken symlinks */
}
@@ -116,7 +120,7 @@ function devProjectApi(): Plugin {
/* dataDir doesn't exist yet */
}
const projectWatcher = watch(realProjectPaths, {
const projectWatcher = watch([...watchedProjects.keys()], {
ignoreInitial: true,
// A project write is a whole-file replace; wait for it to settle so a
// half-written composition is never announced.
@@ -172,6 +176,9 @@ function devProjectApi(): Plugin {
// The engine records its write receipts in this module, where the watcher below reads them.
const adapter = createViteAdapter(dataDir, server, signatureCache, {
openHistory: mod.openProjectHistory,
// Projects can be created or imported after startup. Keep the canonical
// id and real root before the signature cache starts watching them.
onResolveProject: (project) => watchedProjects.set(project.dir, project.id),
});
_api = mod.createStudioApi(adapter);
}
@@ -247,6 +254,8 @@ function devProjectApi(): Plugin {
});
projectWatcher.on("change", (filePath: string) => {
const owner = previewChangeOwner(watchedProjects, filePath);
if (!owner) return;
if (
!filePath.endsWith(".html") &&
!filePath.endsWith(".css") &&
@@ -269,13 +278,7 @@ function devProjectApi(): Plugin {
const receipt = studioServer
? studioServer.identifyFileWrite(filePath, version ?? studioServer.DELETED_VERSION)
: null;
// First path segment under `dataDir` is the project id (`data/projects/<id>/...`).
// Mirrors the CLI host's `project.id` field on the same event — see its
// doc comment for why a stale tab needs this to ignore another
// project's saves on a shared connection. This host is multi-project
// (any dir under `dataDir` resolves), so unlike the CLI host it can't
// assume one fixed id.
const projectId = relative(dataDir, filePath).split(sep)[0];
const projectId = owner;
server.ws.send({
type: "custom",
event: "hf:file-change",
@@ -0,0 +1,49 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { previewChangeOwner } from "./vite.preview-watch";
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe("Vite preview change ownership", () => {
it("uses a symlink target's project id and config without leaking into sibling projects", () => {
const root = mkdtempSync(join(tmpdir(), "hf-vite-watch-"));
dirs.push(root);
const a = join(root, "external");
const b = join(root, "external-other");
for (const path of [a, b]) mkdirSync(path);
writeFileSync(
join(a, "hyperframes.json"),
JSON.stringify({ preview: { watchIgnore: ["docs"] } }),
);
const projects = new Map([
[a, "symlink-project"],
[b, "other-project"],
]);
expect(previewChangeOwner(projects, join(a, "index.html"))).toBe("symlink-project");
expect(previewChangeOwner(projects, join(a, "docs", "report.json"))).toBeNull();
expect(previewChangeOwner(projects, join(a, "index.html.tmp"))).toBeNull();
expect(previewChangeOwner(projects, join(b, "docs", "report.json"))).toBe("other-project");
expect(previewChangeOwner(projects, join(root, "outside.html"))).toBeNull();
});
it("uses the nearest project when watched directories are nested", () => {
const root = mkdtempSync(join(tmpdir(), "hf-vite-watch-"));
dirs.push(root);
const nested = join(root, "nested");
mkdirSync(nested);
expect(
previewChangeOwner(
new Map([
[root, "root"],
[nested, "nested"],
]),
join(nested, "index.html"),
),
).toBe("nested");
});
});
+13
View File
@@ -0,0 +1,13 @@
import { sep } from "node:path";
import { shouldReloadPreview } from "@hyperframes/studio-server";
export function previewChangeOwner(
watchedProjects: ReadonlyMap<string, string>,
filePath: string,
): string | null {
const owner = [...watchedProjects]
.sort(([left], [right]) => right.length - left.length)
.find(([dir]) => filePath.startsWith(dir + sep));
if (!owner || !shouldReloadPreview(owner[0], filePath)) return null;
return owner[1];
}