Repository navigation
fix(studio): reload the dev preview after edits to a symlinked project - #4558
miguel-heygen wants to merge 4 commits into
Conversation
jrusso1020
left a comment
There was a problem hiding this comment.
Review at 9fde5dd4.
Verdict: APPROVE. The fix gives a file in a linked project's real folder the right project id. I checked it on a running dev server: an outside edit to a linked project arrives as projectId: "promo" here and as ".." on main. Nothing blocks. One guard the fix adds keeps the dev server alive when there is a broken link, and no test covers it yet, so I'd add one line to the test.
Root cause and fix
The dev watcher never watches a linked project through its link. At startup it resolves each link and watches the real folder (vite.config.ts:114). Opening the preview adds the real folder again through the signature cache (vite.adapter.ts:275, then vite.config.ts:137). So every change arrives with a real path outside data/projects, and main's relative(dataDir, filePath).split(sep)[0] turned that into ... The client drops a change whose id is set and differs from the open project (useExternalFileChangeCoordinator.ts:385-389), so every outside edit was dropped as "other project".
projectIdForWatchedFile (vite.adapter.ts:46-62) keeps the old rule for paths inside data/projects. For any other path it returns the entry whose real folder contains the file. The call site (vite.config.ts:262-263) now skips a file no entry owns. That skip matters. The client only suppresses a change when the id is present (useExternalFileChangeCoordinator.ts:386), so sending null would have made an unowned file look like the open project's.
vite.config.ts, +9/-9. :12 drops relative and sep from the node:path import, since nothing else in the file uses them. :15-19 adds projectIdForWatchedFile to the adapter import, now split over five lines. :261-263 adds a one-line comment, the id, and the early return, placed before the log line. Removed: the six-line comment and the old const projectId = ... that sat after the receipt lookup.
For a plain project the id is the same string as before. The only other change is order. The id is now computed before the log line, the file read and identifyFileWrite, and an unowned file returns early. identifyFileWrite consumes nothing (packages/studio-server/src/helpers/fileVersion.ts:78-93), so skipping it has no side effect. Dev server with a plain project: projectId: "plain" at this head and on main. There is one exception, in nit 2: when packages/studio/data is itself a link.
Path handling
I ran the PR's helper against chokidar 4.0.3, with the same startup list and options as vite.config.ts:109-128, and made real edits.
| Case | Result |
|---|---|
| Linked project, file at the root and in a subfolder | promo (test, probe, dev server) |
| Plain project | plain, same as main (dev server at both) |
Plain project whose name starts with .. |
..plain. isPathWithin rejects only .. and ../ (vite.adapter.ts:39-40) |
Sibling real folders /a/proj and /a/proj2 |
proj and proj2. The check uses path.relative, not a string prefix (vite.adapter.ts:36-41) |
| Link inside a plain project that points outside | chokidar reports it through the link (data/plain/assets/a.css), so plain |
| Link inside a linked project | reported under the project's real folder, so promo |
Broken link in data/projects |
skipped by the catch at vite.adapter.ts:55-59. Dev server stays up |
| Link pointed at a new folder while the server runs | edits in the old folder resolve to null and are not sent. The new folder is watched once its preview opens (vite.config.ts:137), same as main |
Trailing slash on dataDir |
promo. resolve in isPathWithin drops it |
| One linked project inside another's real folder | whichever entry readdir lists first. The body names this limit. See nit 3 |
| Windows | not run. By reading: the helper calls realpathSync on the same join(dataDir, name) string that vite.config.ts:114 watched, so the two paths match, and path.relative on win32 ignores case |
The test
Green here: vite.adapter.projects.test.ts passes 16 of 16. The whole studio suite passes at this head: 524 files, 5638 tests.
Red on main. With main's vite.adapter.ts and vite.config.ts checked out, the new case fails with TypeError: projectIdForWatchedFile is not a function, because main has no such export. The red that tests the bug is the one the body describes: put main's first-segment rule inside the helper and it fails with AssertionError: expected '..' to be 'promo'. I reproduced both.
Mutations on the fix. Each ran against every test that imports either file (33 tests), and the last one against the whole studio suite.
| Mutation | Result |
|---|---|
| Helper body back to main's first-segment rule | caught: expected '..' to be 'promo' |
| Compare the link path instead of its real path | caught: expected null to be 'promo' |
| Drop the containment check in the loop | caught: expected '..plain' to be 'promo' |
filePath.startsWith(...) instead of isPathWithin |
survives |
| Drop the broken-link catch | survives |
Drop the catch around readdirSync |
survives |
Drop the fast path for files inside data/projects |
survives on Linux, where the loop finds the same answer |
Drop || null on the fast path |
survives. It only matters for a change on dataDir itself, which chokidar does not send |
Call site back to main's rule, and if (!projectId) return removed |
survives all 5638 studio tests |
The last row means the helper is covered and its call site is not. The before and after clips, and my dev-server run, are the only evidence that the watcher calls it.
Blockers
None.
Should-fix
- Cover the broken-link catch (
vite.adapter.ts:55-59). Removing it crashes the dev server. I removed it, put a broken link indata/projects, pointed another link at a new folder and edited the old one. The server printedError: ENOENT: no such file or directory, stat '.../data/projects/aaa-broken'fromrealpathSyncinside the chokidar listener and exited with code 1. With the catch in place, the same steps leave the server up. A broken link is a normal leftover once a linked folder is moved or deleted. One line in the fixture covers it: a broken link next topromo. Thesessions/x.jsonassertion already walks every entry, so it reaches the link in anyreaddirorder.
Nits
- Cover the separator boundary. The code is right, but a
startsWithrewrite still passes the test.mkdirSync(join(root, "elsewhere2"))plusexpect(projectIdForWatchedFile(data, join(root, "elsewhere2", "index.html"))).toBeNull()catches it. - Two copies of each outside edit when
packages/studio/datais a link. A plain project is watched atdata/projects/plainfrom startup, becausevite.config.ts:114leaves plain folders unresolved. Once its preview opens, it is watched again at its real path (:137). On main the second copy carried..and the client dropped it. Here both copies carryplain, with differentpathstrings. Dev server, one outside edit: main sentplainand.., this head sentplaintwice. The client's duplicate check keys on path and version (useExternalFileChangeCoordinator.ts:137-142), so it keeps both. Studio's own saves are fine: the receipt's relative path replaces the absolute one, so the second copy reads as a duplicate (:397). Resolving every entry atvite.config.ts:114, not just links, would watch each folder once. The setup is uncommon, so this is a nit. - Nested links resolve by
readdirorder. With one link at/xand one at/x/sub, an edit in/x/subwent toinnerwheninnerlisted first, and toa-outerwhena-outerlisted first.readdirorder depends on the filesystem, so which tab loses is arbitrary. Preferring the longest matching real folder would make it stable. Optional, since the body already names the limit. - The call site has no test (last mutation row). I'm not asking for one in this PR, since
devProjectApiis not exported and hard-codesdataDir.
Checked
- Body claims.
hyperframes previewlinks the project withsymlinkSync(packages/cli/src/commands/preview.ts:1291-1316). The client drops a mismatched id (useExternalFileChangeCoordinator.ts:385-389). The CLI host always sends its one id (packages/cli/src/server/studioServer.ts:888). The two commits after the recorded clips only swaprealpathSync.nativeforrealpathSyncin the helper and shorten the comment. I diffed7a8e5b0f..9fde5dd4to confirm. - The helper's
realpathSyncis the same call that built the watch list (vite.config.ts:114) and thatresolveProjectuses (vite.adapter.ts:275), so the paths compare equal. - The folder walk only runs for paths outside
data/projects(vite.adapter.ts:47), so plain projects pay nothing extra. - There are no earlier reviews, review comments or issue comments on this PR, so nothing here repeats one.
git merge-tree --write-tree origin/main 9fde5dd4, with main atd290c05bafter a fresh fetch, exits 0 with tree9c047724and no conflicts. Main is 3 commits past the merge base, and none of them touch these files.
Not verified
- Windows and macOS. On macOS the test's
tmpdir()sits under a link (/varto/private/var), so the..plainassertion should catch the fast-path mutation there. I did not run it. - The browser. I watched the
hf:file-changesocket messages, not the preview reloading. The PR's clips cover that at7a8e5b0f. - What the second copy in nit 2 costs in the browser. From the drain code (
useExternalFileChangeCoordinator.ts:282), it should be one extra preview reload.
— Rames
|
Closing as superseded by #4553. It fixes the same bug on main: the Vite dev server now maps each watched project's real root to its id ( Checked on current main two ways:
|
What
In Studio's dev server, an edit made outside Studio to a symlinked project never reloaded the preview.
hyperframes previewlinks a project intodata/projects/<id>with a symlink. The dev server's watcher follows the link and reports changed files by their real path. Thehf:file-changeevent took its project id from the first path segment underdata/projects, which for a real path outside that folder is... Studio drops a file change whose project id is not the open project, as it must for a stale tab on a shared connection, so every external edit to that project was dropped as "other project".projectIdForWatchedFileinvite.adapter.tsnow names the project. A file insidedata/projectskeeps the first-segment rule, which costs nothing. A file outside it is matched against the real path of each project entry. A file no project owns is not announced; the client dropped those anyway. The dev server serves every project underdata/projects, so unlike the CLI host, which always sends its one project's id, it has to name the owning project per file.Scope
Test
vite.adapter.projects.test.ts, "names a symlinked project from the real path the watcher reports". It checks a symlinked project (file at the root and nested), a plain project whose id starts with.., and a file outside every project. With main's first-segment rule in the helper, it fails: expected'..'to be'promo'.Before
Studio dev server with a synthetic test film linked into
data/projectsthe wayhyperframes previewlinks it. An editor outside Studio changes the title from "Test pattern film" to "Edited outside Studio". On main, Studio logs the change assuppressed: other project, no new preview loads, and the old title stays.symlink-edit-before.mp4
After
The same edit: the change is accepted, the new preview loads (the overlay lists its events), and the title reads "Edited outside Studio".
symlink-edit-after.mp4
Both clips were recorded on Linux at 7a8e5b0. The two later commits only resolve project folders with
realpathSyncinstead ofrealpathSync.native(the call the watcher uses; on Linux the two give the same path) and shorten a comment.