Skip to content

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

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/preview-signature-stale-etag
Aug 21, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/preview-signature-stale-etag

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What

The dev server's preview ETag never changed after the first request, so the browser kept serving the composition as it was when it first loaded. Timeline thumbnails are the visible casualty: after an edit they keep rendering the old layout until the dev server is restarted.

Why

server.watch.ignored deliberately excludes data/projects/** from Vite's watcher, because Vite answers a composition edit with a full page reload and Studio wants to decide that itself. The preview signature cache was subscribed to that same watcher, so nothing ever cleared it.

The thumbnail route already content-hashes the composition into its disk cache key, so an edit correctly asks for a fresh capture. The capture is then taken against a stale page, which is why the failure reads as "this clip is showing the wrong frames" rather than "this thumbnail is old".

Reproduction, against a dev server that has served the preview at least once:

# edit any composition under packages/studio/data/projects/<id>/
curl -sI  localhost:5190/api/projects/<id>/preview | grep -i etag   # unchanged
curl -s   'localhost:5190/api/projects/<id>/thumbnail/index.html?t=3.22&v=v3' -o after.jpg

after.jpg uses a t that was never generated before, so nothing is served from the disk cache, and it still shows the pre-edit composition.

How

Studio already runs its own chokidar watcher over exactly these directories. That watcher now owns the invalidation, and the cache asks it to follow any project directory it has not seen yet (project dirs are often symlinks from elsewhere on disk, so they are not covered by the initial scan). All five event types invalidate: 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. isPathWithin loses its export in the same file, since nothing outside the module consumed it.

Not covered here: packages/studio-server/src/helpers/projectSignature.ts carries its own private copy of isPathWithin. Deduplicating across the two packages is worth doing but is not this fix.

Test plan

  • Unit tests added/updated — vite.adapter.signature.test.ts, 6 cases covering memoisation, invalidation for an edited / added / removed file, isolation between projects, watch registration happening once, and path normalisation.
  • Manual testing performed — with the dev server running, edited a clip's background from blue to magenta and requested a thumbnail at a fresh t. Before: blue. After: magenta, and blue again once reverted. The preview ETag now changes on edit and returns to its previous value on revert, which is what a content-addressed signature should do.
  • Full studio suite green: 386 files, 4298 tests.
  • Documentation updated — not applicable.

…es 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.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at b755d97 — all three files end to end, plus the signature / preview / thumbnail path they feed. The diagnosis holds up: ignored: ["**/data/projects/**"] (vite.config.ts:258) really does mean the old server.watcher.on("all") subscription could never fire for a project file, and preview.ts:356 bakes the signature straight into the ETag, so a frozen memo is a permanent 304.

Strengths

  • This lands on the pattern the CLI server already proved rather than inventing a third mechanism: studioServer.ts:356 + :362 create a project watcher and clear cachedProjectSignature from its listener. Converging the vite adapter on the shipped path's design is the right call.
  • vite.adapter.ts:72 replaces the old if (cached) truthiness test with cached !== undefined. No live difference (a 24-char hex digest is never falsy), but correct by construction instead of by luck.
  • watched is kept separate from signatures (:74), so an invalidation can't trigger a second add() for the same dir — and vite.adapter.signature.test.ts:73 pins exactly that. Stacking duplicate chokidar registrations is the obvious way to get this wrong.
  • The move of the watcher block above the middleware registrations keeps server.httpServer?.on("close", ...) (vite.config.ts:223), so dynamically added dirs don't outlive the server.

important — the invalidation is unfiltered, so the thumbnail cache evicts the memo it depends on

vite.config.ts:101-102 invalidates on all five events for anything under a project dir, with no path filter. But the signature's own walk skips 14 directories (projectSignature.ts:18-32), and .thumbnails is one of them — writes there cannot change the signature.

.thumbnails is exactly where the thumbnail route keeps its disk cache: thumbnail.ts:153 is join(project.dir, ".thumbnails"), each capture is written there, and pruneThumbnailCache deletes from it (so unlink fires too). Each capture also loads /api/projects/:id/preview (thumbnail.ts:147), which is a signature read.

The interleave: capture → preview read populates the memo → capture written into .thumbnails/ → add → memo dropped → next capture's preview read recomputes. Populating a timeline row is dozens of captures, so the memo gets discarded on roughly every request under the one workload this PR is about ("Timeline thumbnails are the visible casualty"). /api/projects/:id/signature, documented as "a cheap poll target" that the storyboard polls (projects.ts:43-46), stops being cheap the same way.

Being fair about the cost: it is bounded. createProjectSignature keeps its own module-level cache keyed on a stat-only fingerprint (projectSignature.ts:52, :175), so a recompute after a .thumbnails write re-walks and re-stats the project but does not re-hash file contents. So this is wasted work on a hot path, not a correctness bug, and nothing regresses against today's behaviour where the memo never invalidates at all. That's why it's important and not a blocker.

The filter belongs where the exclusion set already lives, not in the plugin. One trap if you reach for the existing predicate: shouldWatchProjectFile (fileWatcher.ts:29) uses WATCHER_EXCLUDED_DIRS, which I diffed against SIGNATURE_EXCLUDED_DIRS — the two sets are character-identical, 14 entries each. But it excludes .hyperframes, and the signature deliberately re-adds two files from inside it (projectSignature.ts:35-37, via collectProjectSignatureManifestFiles at :104). Filtering with it verbatim would stop studio-motion.json / studio-manual-edits.json edits from ever invalidating — a fresh instance of this same bug. What you want is that exclusion set minus those two paths, exported from projectSignature.ts so both watchers share one predicate (which also retires the duplication your "Not covered here" note defers).

nit — the third parameter defaults to a cache that can never invalidate

vite.adapter.ts:92 defaults signatureCache to createProjectSignatureCache() — no watch, nothing subscribed to invalidate. vite.config.ts:120 is the only call site today and it passes the wired one, so nothing is broken. But createViteAdapter(dataDir, server) still typechecks, and what it produces is precisely the pre-PR bug: a memo nothing clears. Dropping the default puts that on the compiler instead of on a reviewer.

nit — the wiring is the part that broke, and it's the part with no test

All six new cases inject compute, so they cover the cache object's logic and none of the wiring. Re-pointing vite.config.ts:98-102 back at server.watcher tomorrow leaves all six green.

Related: "recomputes for an asset added or removed" (vite.adapter.signature.test.ts:41) doesn't distinguish add from remove — invalidate takes no event type, so both assertions are the same path-containment check. The add/remove claim actually lives in the five-event list at vite.config.ts:101, which is untested. Nothing imports vite.config in a test today, so I'm not asking for a plugin harness — the predicate from the important above is the natural unit: shouldInvalidateSignature(".thumbnails/x.jpg") === false, shouldInvalidateSignature(".hyperframes/studio-motion.json") === true.

nit — the docstring oversells the cost

vite.adapter.ts:43 says the signature "hashes every file in the project." The content hash is already gated behind the stat fingerprint at projectSignature.ts:175, so what this memo actually saves is the directory walk. Worth being accurate, since it's the sentence a future reader will use to decide how aggressive invalidation is allowed to be.

Checked, not findings

  • Symlink spelling. realProjectPaths watches realpathSync(...) while cache keys are resolve(projectDir), which does not resolve symlinks — that looked like a mismatch that would silently skip invalidation for symlinked projects, the case your comment calls out. It isn't one: resolveProject returns dir: realpathSync(projectDir) (vite.adapter.ts:221, also :210), and every signature consumer goes through it — preview.ts:405 / :429 / :518, projects.ts:49, storyboard.ts:51. Both sides are real paths. (listProjects at :188 does hand back the un-realpath'd spelling, but nothing routes it into the cache.)
  • PR-body claims. ignored: ["**/data/projects/**"] present as described; isPathWithin has no consumer outside the module (only :83, :413, plus studio-server's separate private copy); six test cases as stated. Incidentally the two isPathWithin copies are not identical — the adapter's resolves both sides, projectSignature.ts:54 doesn't — worth knowing whichever one survives the dedup.
  • Watcher lifetime / block move. Same options, same paths, still closed on server close, nothing ordering-sensitive between it and the middleware registrations.
  • I did not rerun the studio suite locally. All 8 required contexts are green at b755d97, regression aggregate included, so BLOCKED here is the review gate alone.

Adjacent, pre-existing — the same stale ETag is still live in the CLI studio server

Not this PR's scope, but it's the mirror of what you just fixed and you're already in the area. studioServer.ts:362 clears cachedProjectSignature from the CLI watcher, but that watcher filters through shouldWatchProjectFile, which drops .hyperframes/**. Executing the predicate: .hyperframes/studio-motion.json → false, .hyperframes/studio-manual-edits.json → false. Both files are in the signature, and Studio writes the motion one at runtime (usePreviewPersistence.ts:92). So under hyperframes studio, saving motion state leaves the preview ETag stale until restart — same failure mode, shipped path. The shared-predicate fix above closes both at once.

Verdict: COMMENT
Reasoning: The fix is correctly diagnosed and well-targeted, CI is fully green, and I found no blocker — the one important is wasted work inside the new invalidation rather than a regression. Not stamping because the ask was a review and not an approval; happy to convert once the filter question is settled either way.

— Rames Jusso

…issing 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.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Thanks — the .thumbnails interleave was the one I'd missed, and it's the workload the memo exists for, so it mattered. All four addressed, plus the adjacent CLI bug.

important — unfiltered invalidation. Fixed with affectsProjectSignature(projectDir, changedPath), exported from projectSignature.ts beside the exclusion set it reads. Applied inside invalidate rather than at the watcher, so a future caller can't subscribe and forget the filter — the cache owns what can change its value.

Took the trap you flagged: it is not WATCHER_EXCLUDED_DIRS. The predicate checks the two manifest paths first, so .hyperframes/studio-motion.json and .hyperframes/studio-manual-edits.json still invalidate while the rest of .hyperframes/ doesn't. One deliberate call: it tests every segment, not just the parents, so a directory event on an excluded dir itself (unlinkDir .thumbnails, which the prune emits) is filtered too. The cost is that a file literally named dist at the project root reads as excluded — a false negative the walk would have collected. That's written down in the docstring.

Adjacent CLI bug — fixed here too. I executed the predicate and it lands as you described: shouldWatchProjectFile(".hyperframes/studio-motion.json") is false, so the listener clearing cachedProjectSignature never ran and saving motion state left the ETag stale until restart.

The fix had a trap of its own. Widening the watcher wholesale would have made live-reload fire on every motion save, since Studio writes that file at runtime — a browser reload mid-edit. So the ingest gate is now the union of the two predicates, which admits exactly today's set plus those two files, and the reload listener re-applies shouldWatchProjectFile for itself. What triggers a reload is byte-identical to before; only the signature listener sees the new paths.

nit — default cache that can never invalidate. Dropped. createViteAdapter(dataDir, server) no longer typechecks.

nit — docstring oversells the cost. Corrected to say it saves the walk, with the stat-fingerprint gate called out for the next reader.

nit — the wiring has no test. Took your suggestion rather than a plugin harness: the predicate is the unit, and it's pinned in a new projectSignature.test.ts (6 cases, including .thumbnails/frame-0.jpg → false and .hyperframes/studio-motion.json → true). Two more at the cache level assert a .thumbnails write leaves the memo intact and a motion save drops it. You're right that re-pointing the plugin at server.watcher would still pass everything; I didn't solve that.

Suites, each with its own package config: studio 4300 passed, cli 2780 passed, studio-server 452 passed.

Not doing here: the two isPathWithin copies are still separate. Worth the dedup you describe, but it touches consumers beyond this fix and reads better as its own change.

@miguel-heygen
miguel-heygen merged commit 5842dd8 into main Aug 21, 2026
60 checks passed
@miguel-heygen
miguel-heygen deleted the fix/preview-signature-stale-etag branch August 21, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants