Repository navigation
feat(vrowzer): resolve file operations after the Workers apply them - #61
Merged
Merged
Conversation
V_FS_WRITE and V_FS_UNLINK accept an optional id, which the publisher sets when writeFile() or unlink() is called with { id }. A Worker that applies such a message can answer with the new V_FS_ACK message, carrying the id and, when applying failed, the error's name and message. @vrowzer/fs does not send acknowledgements by itself.
Binary content is now copied for every target. Before, the first target received the caller's ArrayBuffer through the transfer list, which detached it on the caller's side.
…dation setupHMR() now handles each watcher event in two parts: up to module graph invalidation (the plugins' watchChange hooks, the public file list, and invalidation), then HMR. It records the first part for each path and returns a tracker whose waitForFileChange(file) takes the latest event for that path. The Web Worker server exposes it as waitForFileChange(), so that a caller that writes a file and emits the event can wait until later transform requests see the change, without waiting for HMR. The promise rejects when the first part fails, which is still logged as before. A deleted file is now invalidated before HMR as well. onFileDelete() keeps the deleted file's cached transform result, which Vite clears only during the HMR update, so a read after the first part could still get the deleted file's code.
A V_FS_WRITE or V_FS_UNLINK message with an id is now answered with V_FS_ACK. The Web Worker answers after the dev server has processed the file change up to module graph invalidation, using waitForFileChange() right after applying the message, and does not wait for HMR. The Service Worker writes its virtual filesystem synchronously and answers the client that sent the message right away. When applying fails, the acknowledgement carries the error's name and message. Messages without an id are handled as before.
addFile(), updateFile() and deleteFile() now return a promise that resolves when later preview requests see the change: both Workers have written their virtual filesystems, and the Web Worker has invalidated the modules that depend on the file. HMR is not awaited. Each operation gets an id, and the instance waits for V_FS_ACK from the Web Worker and from the Service Worker. The promise rejects, naming the operation, the path and the Worker, when a Worker fails to apply the change, when the Web Worker reports an error, when the replies do not arrive within the new fileSyncTimeout option (10 seconds by default), or when the instance is disposed first. Replies with unknown ids, such as those for another instance in the same page, are ignored. The file methods accept operations only once ready() has resolved to true. Before that, while ready() is in progress, after it failed and after dispose(), they reject without sending anything. After dispose() they used to throw, but that behavior has not been released yet. The integration tests and the e2e helpers now wait for the file operations. BREAKING CHANGE: addFile(), updateFile() and deleteFile() return Promise<void> instead of void. They reject when called before ready() resolves to true or after it fails; such calls used to be dropped, or to reach only the Web Worker while ready() was in progress. Callers that ignore the returned promise see failures as unhandled rejections.
updateFile() now returns a promise that rejects when the change cannot be applied, for example before the runtime is ready. The playground and the vite-plugin IDE keep updating the preview without waiting, and log such failures instead of leaving unhandled rejections.
A playground plugin that runs in the Web Worker lets the tests hold or fail how a file change is applied: it holds watchChange of updates under /file-sync/held/, fails watchChange once per path under /file-sync/fail-once/, and holds the HMR update of deletions under /file-sync/held-hmr/, until /file-sync/release is written. The tests check that an update stays pending while the Web Worker has not invalidated the module, while reads still get the cached result, and that reads after it resolves get the new content. They cover adding, updating, rewriting the same content, an unused file, deleting, deleting again and re-adding a path; binary content, which keeps the caller's buffer usable and reaches both Workers with the same bytes; a failed change, which rejects and converges when the file is written again; and a deletion, which is visible to reads before its HMR update finishes. They fail when the Web Worker acknowledges without waiting for invalidation, or when deletions are invalidated only by HMR.
Describe when addFile(), updateFile() and deleteFile() resolve and reject, the fileSyncTimeout option, ordering, resynchronization after a failure, and how to wait before reloading a preview. The dispose() section now says that file operations reject after disposal. The note records that the methods returned void up to 0.4.x. Both the README and the JSDoc state a current limitation: public files under /public/ are not served by the Service Worker, whether they are passed to ready() or added later, because it builds its list of public files when it starts. The @vrowzer/fs README covers the operation ids, V_FS_ACK and the per-target binary copies. The API references are regenerated.
The file synchronization tests read modules through the preview iframe, and a module read that way has no importer. Changing it later was an HMR dead end that reloaded the iframe, and a reload that overlapped a read in the next test made the iframe's fetch() fail. The tests now read through the Service Worker from the host page, which HMR does not reload, and their modules accept their own updates, so that they do not reload the preview for the tests that follow.
This was referenced Oct 1, 2026
kazupon
added a commit
that referenced
this pull request
Oct 1, 2026
* fix(fs): copy binary files for each target in initFiles() initFiles() built one transfer list from the caller's ArrayBuffers and passed it to every target. The first postMessage() detached the buffers, so with two or more targets the next one threw a DataCloneError after only the first target had received the files. With one target, the caller's buffers ended up detached. The same ArrayBuffer given for two paths also threw a DataCloneError, even with one target, because the transfer list held it twice. As writeFile() does since #61, initFiles() now gives each target its own copy of every binary file and transfers only the copies, so the caller's ArrayBuffers stay usable. broadcast() no longer takes a transfer list, because one list cannot be transferred to more than one target. The JSDoc of FSInitMessage and initFiles() describes the copies. * docs(fs): describe the binary copies of initFiles() The README example of initFiles() now passes a binary file as well, and notes that each target gets its own copy, as the writeFile() example does. The API references are regenerated for the JSDoc of FSInitMessage and initFiles().
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #42
addFile(),updateFile()anddeleteFile()now return a promise that resolves when later preview requests see the change.Breaking change
Up to 0.4.x, the three methods returned
voidand only posted messages to the Workers. They now returnPromise<void>:ready()resolves totrue, or after it fails. Such calls used to be dropped, or to reach only the Web Worker whileready()was in progress.dispose(). feat(vrowzer): dispose a runtime instance asynchronously #60 made them throw afterdispose(), but that has not been released.When the promise settles
crypto.randomUUID()). The Web Worker and the Service Worker answer with a newV_FS_ACKmessage, and the promise resolves when both have answered.watchChangehooks, the public file list, and the invalidation itself.watchChangehook throws (the Web Worker used to only log this);errorevent;fileSyncTimeoutoption (10 seconds by default);ArrayBufferwas transferred to the first target, which detached it.Deletions are invalidated before HMR
moduleGraph.onFileDelete()keeps the deleted file's cached transform result. Vite clears it only during the HMR update (updateModules()), and never whenserver.hmrisfalse. The promise does not wait for HMR, so a read right afterdeleteFile()resolved could still get the deleted file's code.Deletions now also call
moduleGraph.onFileChange()before HMR, in the same order as changes. The integration test that holds the HMR update of a deletion fails without this.Timeout
10 seconds is shorter than the 30-second RPC timeout between the Service Worker and the Web Worker, so with slow plugins an operation can reject although the change is applied later. The README and the option's JSDoc say to increase
fileSyncTimeoutin such environments, and that writing the file again resynchronizes it.Known limitation: public files
The Service Worker builds its list of public files when it starts, when only
/public/.gitkeepexists, and never updates it. I checked in the browser that a file under/public/is not served at the preview root, whether it is passed toready()or added later. The request gets the SPA fallbackindex.htmlinstead.This PR does not change that. The README and the JSDoc of
addFile()state the limitation, and the fix is left for a separate issue.Out of scope
@vrowzer/fs'sinitFiles()passes the same transfer list to every target. With two or more targets and binary files, the secondpostMessage()should therefore fail on a detachedArrayBuffer. Vrowzer does not useinitFiles(). I found this by reading the code and did not reproduce it.Tests
Unit tests:
@vrowzer/fs: ids on messages,V_FS_ACK, and binary copies. A test with a realMessageChannelchecks that the caller's buffer stays usable.waitForFileChange()waits forwatchChangeand invalidation but not for HMR, rejects whenwatchChangefails, keeps the events of the same path separate, and forgets events that settled without being taken.Vrowzer, using fake timers where timing matters:ready;Integration tests in the shared playground. A Web Worker test plugin holds or fails
watchChange, and holds the HMR update of deletions. The tests check that:The tests fail when the Web Worker answers without waiting for invalidation, or when only HMR invalidates deletions.
They read through the Service Worker from the host page. A read from the preview iframe could be interrupted by an HMR full reload of the iframe, which made one run fail.
The existing integration tests and the e2e helpers now wait for the file operations.
Validated locally with:
vp run test:unit: 118 files, 1,901 testsvp run test:integration:vrowzer: 60 passed and 1 conditional skip, in three consecutive runsvp run test:e2e: 46 passed, 6 existingtest.skip