fix(studio): invalidate the preview signature off the watcher that sees project writes (#3364)

* fix(studio): invalidate the preview signature off the watcher that sees project writes

The preview ETag is a hash of the project's files, memoised per project
directory. That cache was cleared from Vite's own watcher, which
`server.watch.ignored` deliberately excludes `data/projects/**` from, so
nothing ever cleared it: the ETag stayed frozen for the life of the dev
server, the preview answered every revalidation with 304, and the browser
went on serving the composition as it was when it first loaded.

The visible cost is thumbnails. Their disk cache key already content-hashes
the composition, so an edit correctly asks for a fresh capture, but the
capture is taken against the stale page, and a clip's filmstrip keeps
showing frames of a layout that no longer exists until the dev server is
restarted.

Studio already runs its own chokidar watcher over exactly these
directories, because Vite's would answer a composition edit with a full
page reload. That watcher now owns the invalidation, and the cache asks it
to follow any project directory it has not seen. All five event types
count: an added or deleted asset changes the signature as surely as an
edited one.

The cache moves behind `createProjectSignatureCache` so the invalidation
rule is a unit under test rather than a subscription buried in the adapter.

* fix(studio): filter signature invalidation, and stop the CLI server missing motion saves

Review follow-up on the unfiltered invalidation.

The watcher fired on everything under a project dir, but the signature walk
skips 14 directories and `.thumbnails` is one of them. That directory is
where the thumbnail route keeps its disk cache, and every capture also reads
the preview, so populating a timeline row discarded the memo on roughly every
request of the one workload it exists for.

The filter is a single exported predicate beside the exclusion set it reads,
and it is applied inside `invalidate` rather than at the watcher, so no caller
can subscribe and forget it. It is deliberately not `WATCHER_EXCLUDED_DIRS`:
that set is character-identical but drops all of `.hyperframes/`, and the
signature reads two manifest files back out of there.

Which is the same bug, still live, in the CLI server: its watcher filters
through `shouldWatchProjectFile`, so `.hyperframes/studio-motion.json` never
reached the listener that clears the cached signature. Studio writes that file
at runtime, so saving motion state left the preview ETag stale until restart.
The watcher now admits signature-relevant paths and the reload listener
re-applies its own filter, so what triggers a browser reload is unchanged.

Also from review: drop the `createViteAdapter` signature-cache default, which
produced exactly the memo-nothing-clears bug this PR fixes, and correct the
docstring — the content hash is already gated behind a stat fingerprint, so
what the memo saves is the walk.
This commit is contained in:
Miguel Ángel
2026-08-21 19:13:37 -04:00
committed by GitHub
parent 09a5ef7092
commit 5842dd8df4
8 changed files with 328 additions and 53 deletions
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { resolve } from "node:path";
import { affectsProjectSignature } from "./projectSignature.js";
const PROJECT = resolve("/projects/demo");
const affects = (relativePath: string) =>
affectsProjectSignature(PROJECT, resolve(PROJECT, relativePath));
describe("affectsProjectSignature", () => {
it("accepts a file the signature walk collects", () => {
expect(affects("index.html")).toBe(true);
expect(affects("assets/logo.png")).toBe(true);
});
it("rejects the caches the walk skips", () => {
// .thumbnails is the one that matters: the thumbnail route writes a capture
// there and reads the preview on the next one, so invalidating on it throws
// the memo away on roughly every request of the workload it exists for.
expect(affects(".thumbnails/frame-0.jpg")).toBe(false);
expect(affects("node_modules/pkg/index.js")).toBe(false);
expect(affects("renders/out.mp4")).toBe(false);
});
it("rejects a directory event on an excluded dir itself", () => {
expect(affects(".thumbnails")).toBe(false);
});
it("accepts the two manifest files the signature reads back out of .hyperframes", () => {
// The reload watcher's exclusion set is character-identical to the walk's but
// drops all of .hyperframes/. Filtering with it would stop a motion-state save
// from ever invalidating — the same stale-ETag bug in a new place.
expect(affects(".hyperframes/studio-motion.json")).toBe(true);
expect(affects(".hyperframes/studio-manual-edits.json")).toBe(true);
});
it("rejects everything else inside .hyperframes", () => {
expect(affects(".hyperframes/cache/blob.bin")).toBe(false);
});
it("rejects a path outside the project", () => {
expect(affectsProjectSignature(PROJECT, resolve("/projects/other/index.html"))).toBe(false);
expect(affectsProjectSignature(PROJECT, PROJECT)).toBe(false);
});
});
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { lstatSync, readFileSync, readdirSync } from "node:fs";
import { extname, isAbsolute, relative, resolve } from "node:path";
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
import type { ResolvedProject, StudioApiAdapter } from "../types.js";
const SIGNATURE_TEXT_EXTENSIONS = new Set([
@@ -37,6 +37,36 @@ const STUDIO_SIGNATURE_MANIFEST_PATHS = [
".hyperframes/studio-motion.json",
] as const;
/**
* Whether a write at `changedPath` can change `createProjectSignature(projectDir)`.
*
* Owned here because it is the exact complement of what the walk below collects,
* and a second copy of that reasoning drifts the moment either set changes. A
* watcher that invalidates on everything is not merely wasteful: `.thumbnails/`
* is written by the thumbnail route on every capture, and each capture reads the
* preview, so an unfiltered watcher discards the memo on roughly every request of
* the one workload the memo exists for.
*
* Note this is not `WATCHER_EXCLUDED_DIRS`, which is character-identical but
* excludes all of `.hyperframes/` — the signature deliberately reads two manifest
* files from inside it, so filtering with that set would stop motion-state saves
* from ever invalidating.
*
* Every segment is tested, not just the parents, so a directory event on an
* excluded dir itself (`unlinkDir .thumbnails`) is filtered too. The cost is that
* a *file* literally named `dist` at the project root reads as excluded; the walk
* would collect it, so it is a false negative, and no real project has one.
*/
export function affectsProjectSignature(projectDir: string, changedPath: string): boolean {
const relativePath = relative(resolve(projectDir), resolve(changedPath));
if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
return false;
}
const segments = relativePath.split(sep);
if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/") as never)) return true;
return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
}
interface ProjectSignatureFile {
file: string;
mtimeMs: number;
+1 -1
View File
@@ -1,5 +1,5 @@
export { createStudioApi } from "./createStudioApi.js";
export { createProjectSignature } from "./helpers/projectSignature.js";
export { createProjectSignature, affectsProjectSignature } from "./helpers/projectSignature.js";
export type {
StudioApiAdapter,
ResolvedProject,