Skip to content

fix(studio): reload the dev preview after edits to a symlinked project - #4558

Closed
miguel-heygen wants to merge 4 commits into
mainfrom
fix/dev-host-symlink-project-id
Closed

miguel-heygen wants to merge 4 commits into
mainfrom
fix/dev-host-symlink-project-id

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

What

In Studio's dev server, an edit made outside Studio to a symlinked project never reloaded the preview.

hyperframes preview links a project into data/projects/<id> with a symlink. The dev server's watcher follows the link and reports changed files by their real path. The hf:file-change event took its project id from the first path segment under data/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".

projectIdForWatchedFile in vite.adapter.ts now names the project. A file inside data/projects keeps 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 under data/projects, so unlike the CLI host, which always sends its one project's id, it has to name the owning project per file.

Scope

  • The CLI host is not affected: it serves one project and always sends that project's id.
  • Known limit: when two entries resolve to the same real folder, the first entry wins. That takes two links to one folder, or one project linked inside another.
  • Not covered, same as main: opening a symlinked project through a URL whose letter case differs from the link's name.

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/projects the way hyperframes preview links it. An editor outside Studio changes the title from "Test pattern film" to "Edited outside Studio". On main, Studio logs the change as suppressed: 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 realpathSync instead of realpathSync.native (the call the watcher uses; on Linux the two give the same path) and shorten a comment.

@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.

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

  1. Cover the broken-link catch (vite.adapter.ts:55-59). Removing it crashes the dev server. I removed it, put a broken link in data/projects, pointed another link at a new folder and edited the old one. The server printed Error: ENOENT: no such file or directory, stat '.../data/projects/aaa-broken' from realpathSync inside 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 to promo. The sessions/x.json assertion already walks every entry, so it reaches the link in any readdir order.

Nits

  1. Cover the separator boundary. The code is right, but a startsWith rewrite still passes the test. mkdirSync(join(root, "elsewhere2")) plus expect(projectIdForWatchedFile(data, join(root, "elsewhere2", "index.html"))).toBeNull() catches it.
  2. Two copies of each outside edit when packages/studio/data is a link. A plain project is watched at data/projects/plain from startup, because vite.config.ts:114 leaves 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 carry plain, with different path strings. Dev server, one outside edit: main sent plain and .., this head sent plain twice. 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 at vite.config.ts:114, not just links, would watch each folder once. The setup is uncommon, so this is a nit.
  3. Nested links resolve by readdir order. With one link at /x and one at /x/sub, an edit in /x/sub went to inner when inner listed first, and to a-outer when a-outer listed first. readdir order 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.
  4. The call site has no test (last mutation row). I'm not asking for one in this PR, since devProjectApi is not exported and hard-codes dataDir.

Checked

  • Body claims. hyperframes preview links the project with symlinkSync (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 swap realpathSync.native for realpathSync in the helper and shorten the comment. I diffed 7a8e5b0f..9fde5dd4 to confirm.
  • The helper's realpathSync is the same call that built the watch list (vite.config.ts:114) and that resolveProject uses (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 at d290c05b after a fresh fetch, exits 0 with tree 9c047724 and 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 (/var to /private/var), so the ..plain assertion should catch the fast-path mutation there. I did not run it.
  • The browser. I watched the hf:file-change socket messages, not the preview reloading. The PR's clips cover that at 7a8e5b0f.
  • 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

@miguel-heygen
miguel-heygen added this pull request to the merge queue Sep 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 27, 2026
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

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 (previewChangeOwner in packages/studio/vite.preview-watch.ts) and registers projects opened after startup, with its own test for a symlinked project.

Checked on current main two ways:

  • this PR's symlinked-project cases, run against main's previewChangeOwner, all resolve an outside edit to the right project;
  • the same recording as this PR's Before: an outside edit to a symlinked project now reloads the preview.

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