mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
fix(studio): stop a Studio edit from reloading the preview (#3137)
* fix(studio): stop a Studio edit from reloading the preview as if it were external Every mutation route wrote the file without leaving a write receipt, so the watcher's broadcast of Studio's own edit arrived with no identity on it. The external-change coordinator could not tell that echo from an agent or an editor writing the file behind Studio's back, so it took the safe branch and did a full iframe reload. That reload hides the stage for the length of the reload, which is what the flash after a text edit was. Every mutation write now goes through one helper that records the receipt, and the client claims the write before the request goes out rather than after it: the server writes and the watcher fires while the request is still in flight, so a token marked from the response can arrive after the echo it was meant to match. Reproduced in the browser before and after, with the reload path traced end to end. Before, a patch-element write logged `token: null` then a reload from the coordinator; after, the same write logs the token and `suppressed: own write token`, with no reload. Adds `hf-reload-debug` (localStorage, off by default) alongside the existing `hf-resize-debug`: it records each file-change decision and its reason, plus the stack of whoever asked for a full reload. * fix(studio): claim the timeline and caption writes too, not just the DOM ones The receipt only helps when the client marked the token it sent, and the GSAP mutation writers never sent one. A drag commits through gsap-mutations, so the server minted a token the client had never seen, the change came back looking like someone else's, and the preview did the full reload the receipt was meant to prevent. Same one-line claim on both GSAP mutation writers, the timing sync's mutation call, and the caption auto-save PUT. The rollback call stays deliberately unclaimed and says why: it runs because a mutation did not converge, so the preview is on bytes nobody can vouch for and the reload is the point. Verified live: a drag-shaped update-properties on the timeline now logs `suppressed: own write token` with no reload, where it logged a coordinator reload before. * refactor(studio): keep timelineTimingSync under the size cap Claiming the timeline writes pushed this file one line past the 600-line gate. Same change as the branch made later, landed with the commit that caused it. * fix(studio): cover remaining write receipt paths * fix(studio): preserve batch write receipts * fix(cli): emit every file in a watcher burst
This commit is contained in:
@@ -1,12 +1,21 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WatchCallback = (eventType: string, filename: string | Buffer | null) => void;
|
||||
|
||||
const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void };
|
||||
mockWatcher.close = vi.fn();
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
watch: vi.fn(() => mockWatcher),
|
||||
}));
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...original,
|
||||
watch: vi.fn((_path: string, _options: unknown, onChange: WatchCallback) => {
|
||||
mockWatcher.on("change", onChange);
|
||||
return mockWatcher;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { shouldWatchProjectFile, createProjectWatcher } = await import("./fileWatcher.js");
|
||||
|
||||
@@ -30,6 +39,27 @@ describe("shouldWatchProjectFile", () => {
|
||||
});
|
||||
|
||||
describe("createProjectWatcher", () => {
|
||||
beforeEach(() => {
|
||||
mockWatcher.removeAllListeners();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("notifies once for every file changed in one debounce burst", () => {
|
||||
vi.useFakeTimers();
|
||||
const projectWatcher = createProjectWatcher("/fake/project/dir");
|
||||
const listener = vi.fn();
|
||||
projectWatcher.addListener(listener);
|
||||
|
||||
mockWatcher.emit("change", "change", "scene-a.html");
|
||||
mockWatcher.emit("change", "change", "scene-b.html");
|
||||
mockWatcher.emit("change", "change", "scene-a.html");
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(listener.mock.calls).toEqual([["scene-a.html"], ["scene-b.html"]]);
|
||||
projectWatcher.close();
|
||||
});
|
||||
|
||||
// Regression: fs.watch can fail asynchronously (e.g. EMFILE from exhausted
|
||||
// OS watch handles) via an 'error' event, not a thrown exception. An
|
||||
// EventEmitter 'error' with no listener crashes the whole process — this
|
||||
|
||||
@@ -34,6 +34,7 @@ export function shouldWatchProjectFile(filename: string): boolean {
|
||||
|
||||
export function createProjectWatcher(projectDir: string): ProjectWatcher {
|
||||
const listeners = new Set<FileChangeListener>();
|
||||
const pendingPaths = new Set<string>();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let watcher: FSWatcher | null = null;
|
||||
|
||||
@@ -43,10 +44,16 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
|
||||
const relativePath = filename.toString();
|
||||
if (!shouldWatchProjectFile(relativePath)) return;
|
||||
|
||||
pendingPaths.add(relativePath);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
for (const fn of listeners) {
|
||||
fn(relativePath);
|
||||
const changedPaths = [...pendingPaths];
|
||||
pendingPaths.clear();
|
||||
debounceTimer = null;
|
||||
for (const changedPath of changedPaths) {
|
||||
for (const fn of listeners) {
|
||||
fn(changedPath);
|
||||
}
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
});
|
||||
@@ -72,6 +79,7 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
|
||||
},
|
||||
close() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
pendingPaths.clear();
|
||||
watcher?.close();
|
||||
listeners.clear();
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createProjectSignature,
|
||||
createBackgroundRemovalJob,
|
||||
consumeFileWriteReceipt,
|
||||
fileContentVersion,
|
||||
getMimeType,
|
||||
type PreviewApiAdapter,
|
||||
thumbnailDeviceScaleFactor,
|
||||
@@ -752,7 +753,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
app.get("/api/events", (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const listener = (path: string) => {
|
||||
const receipt = consumeFileWriteReceipt(resolve(projectDir, path));
|
||||
const absPath = resolve(projectDir, path);
|
||||
let version: string | null = null;
|
||||
try {
|
||||
version = fileContentVersion(readFileSync(absPath, "utf-8"));
|
||||
} catch {
|
||||
// A deletion has no current bytes to match against an API write receipt.
|
||||
}
|
||||
const receipt = version ? consumeFileWriteReceipt(absPath, version) : null;
|
||||
stream
|
||||
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
|
||||
.catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user