fix(studio): match the write receipt in dev, so an edit stops reloading the preview (#3206)

## What

Editing anything in the canvas on the dev server reloaded the preview iframe. It no longer does.

## Why

The write receipt exists to prevent exactly this: Studio marks its own writes so the file-watcher echo can be told apart from somebody editing the file underneath it. The receipt is matched on the file's current bytes as well as its path, so `consumeFileWriteReceipt(absPath, expectedVersion)` takes a version.

The dev plugin called it with the path alone. `expectedVersion` was `undefined`, the version comparison never matched, and so every Studio write looked external and reloaded the preview. The CLI server — which is what ships — has always passed the version, so this is dev-server only.

## How

The plugin reads the file and passes its version, the same way `studioServer.ts` does, and treats a deletion (no readable bytes) as unmatched.

## Test plan

Driven on the dev server against a real composition, with `hf-reload-debug` on:

- Before: a drag logged `file-change` with a full external path, then `reload`, then `refreshPlayer`, and the iframe navigated — one reload per edit.
- After: the same drag logs `file-change` carrying the write token, then `suppressed: own write token`. Iframe reloads are zero across drag, resize and an inline text edit.
- Full studio suite (3727), format and lint green.

Found while chasing a flash after every canvas edit. The other half of that flash was Vite's own HMR full-reloading the page, fixed separately in #3163; with both in, the canvas stops flashing.
This commit is contained in:
Miguel Ángel
2026-08-11 01:37:35 -04:00
committed by GitHub
parent 20d915938b
commit fceb376551
+19 -6
View File
@@ -68,11 +68,11 @@ function devProjectApi(): Plugin {
createStudioApi: (adapter: ReturnType<typeof createViteAdapter>) => { createStudioApi: (adapter: ReturnType<typeof createViteAdapter>) => {
fetch: (req: Request) => Promise<Response>; fetch: (req: Request) => Promise<Response>;
}; };
consumeFileWriteReceipt?: (path: string) => { consumeFileWriteReceipt?: (
path: string; path: string,
version: string; expectedVersion: string,
writeToken: string; ) => { path: string; version: string; writeToken: string } | null;
} | null; fileContentVersion?: (content: string) => string;
} | null = null; } | null = null;
const getApi = async () => { const getApi = async () => {
if (!_api) { if (!_api) {
@@ -177,7 +177,20 @@ function devProjectApi(): Plugin {
) )
return; return;
console.log(`[Studio] File changed: ${filePath}`); console.log(`[Studio] File changed: ${filePath}`);
const receipt = _studioServerModule?.consumeFileWriteReceipt?.(filePath) ?? null; // The receipt is matched on the file's current bytes, not just its path,
// so a write is only recognised as ours when the version agrees. Calling
// this without the version could never match, which left every Studio
// write looking external and reloaded the preview on each edit.
let version: string | null = null;
try {
version =
_studioServerModule?.fileContentVersion?.(readFileSync(filePath, "utf-8")) ?? null;
} catch {
// A deletion has no current bytes to match a write receipt against.
}
const receipt = version
? (_studioServerModule?.consumeFileWriteReceipt?.(filePath, version) ?? null)
: null;
server.ws.send({ server.ws.send({
type: "custom", type: "custom",
event: "hf:file-change", event: "hf:file-change",