Skip to content

feat(vrowzer): resolve file operations after the Workers apply them - #61

Merged
kazupon merged 8 commits into
mainfrom
feat/file-sync-promises
Oct 1, 2026
Merged

kazupon merged 8 commits into
mainfrom
feat/file-sync-promises

Conversation

@kazupon

@kazupon kazupon commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Closes #42

addFile(), updateFile() and deleteFile() now return a promise that resolves when later preview requests see the change.

Breaking change

Up to 0.4.x, the three methods returned void and only posted messages to the Workers. They now return Promise<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.
  • They reject after dispose(). feat(vrowzer): dispose a runtime instance asynchronously #60 made them throw after dispose(), but that has not been released.
  • Callers that ignore the returned promise see failures as unhandled rejections. The playground and the vite-plugin IDE now log them.

When the promise settles

  • Each operation gets an id (crypto.randomUUID()). The Web Worker and the Service Worker answer with a new V_FS_ACK message, and the promise resolves when both have answered.
  • The Service Worker answers right after writing its virtual filesystem, which is synchronous.
  • The Web Worker answers after the dev server has processed the file change up to module graph invalidation: the plugins' watchChange hooks, the public file list, and the invalidation itself.
    • It waits only for the processing of that path, which it takes right after applying the message, so a slow change of another file does not delay it.
    • HMR runs afterwards and is not awaited.
  • The promise rejects with an error that names the operation, the path and the Worker, when:
    • a Worker fails to apply the change, for example when a plugin's watchChange hook throws (the Web Worker used to only log this);
    • the Web Worker reports an error event;
    • the answers do not arrive within the new fileSyncTimeout option (10 seconds by default);
    • the instance is disposed first.
  • Answers with unknown ids, such as those for another instance in the same page, and late answers are ignored.
  • Binary content is copied for each target. Before, the caller's ArrayBuffer was 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 when server.hmr is false. The promise does not wait for HMR, so a read right after deleteFile() 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 fileSyncTimeout in 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/.gitkeep exists, 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 to ready() or added later. The request gets the SPA fallback index.html instead.

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

Tests

  • Unit tests:

    • @vrowzer/fs: ids on messages, V_FS_ACK, and binary copies. A test with a real MessageChannel checks that the caller's buffer stays usable.
    • vite-dev-server: waitForFileChange() waits for watchChange and invalidation but not for HMR, rejects when watchChange fails, keeps the events of the same path separate, and forgets events that settled without being taken.
    • The Web Worker and Service Worker cores acknowledge a message after applying it, with the error when applying fails.
    • Vrowzer, using fake timers where timing matters:
      • both answers are required;
      • error answers;
      • the timeout;
      • Web Worker errors;
      • unknown and late answers;
      • two instances in one page;
      • the states other than ready;
      • binary copies;
      • a failure to send;
      • disposal.
    • All new tests failed before the change, except two that check behavior kept unchanged.
  • 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:

    • an update stays pending while the Web Worker has not invalidated the module, reads still get the cached result in the meantime, and reads after it resolves get the new content;
    • adding, updating, writing the same content, adding an unused file, deleting, deleting again and re-adding all resolve, and reads follow them;
    • binary content reaches both Workers with the same bytes, and the caller's buffer stays usable;
    • a failed change rejects, and writing the file again converges;
    • a deletion is visible before its HMR update finishes.

    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 tests
  • vp run test:integration:vrowzer: 60 passed and 1 conditional skip, in three consecutive runs
  • vp run test:e2e: 46 passed, 6 existing test.skip
  • oxlint (no new warnings), knip, the format check of the changed files, and typecheck

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.
@kazupon
kazupon merged commit ba60d7b into main Oct 1, 2026
4 checks passed
@kazupon
kazupon deleted the feat/file-sync-promises branch October 1, 2026 07:56
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().
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.

Return promises when file changes are visible to preview reads

1 participant