Skip to content

feat(bundled-dev): support hotUpdate and handleHotUpdate hook - #22956

Draft
h-a-n-a wants to merge 2 commits into
mainfrom
feat/hot-update
Draft

h-a-n-a wants to merge 2 commits into
mainfrom
feat/hot-update

Conversation

@h-a-n-a

@h-a-n-a h-a-n-a commented Jul 16, 2026 •

Copy link
Copy Markdown
Member

Makes Vite's hotUpdate and handleHotUpdate plugin hooks work in bundled dev. Built on top of rolldown's new dev-only hotUpdate hook (rolldown/rolldown#10305).

Problem

In bundled dev, the rolldown engine owns the module graph and the update flow. When a file changes, hooks run inside the engine, and the engine passes plain module ids. Vite plugins expect Vite's own types: EnvironmentModuleNode objects, a moduleGraph, and one shared context per change event. With no translation between the two sides, every plugin that uses these hooks does nothing in bundled dev.

Even a plugin as small as this had no effect before this PR:

{
  name: 'my-plugin',
  hotUpdate({ file, modules }) {
    if (!file.endsWith('.css')) return
    return modules.filter((m) => m.id !== skippedId) // never ran in bundled dev
  },
}

Design

flowchart TB
  A[Vite plugin<br/>hotUpdate with module nodes] --> B[adapter in bundledDevHmr.ts<br/>one wrapper per plugin]
  B --> C[rolldown hotUpdate hook<br/>plain module ids]
  C --> D[rolldown dev engine<br/>owns graph and update flow]
Loading

Two new pieces, both in bundledDevHmr.ts:

  • A module graph view (BundledModuleGraph) — an object that looks like Vite's module graph but stores no graph data of its own. It creates a Vite module node for an id the first time that id is asked for, then reuses the same node. Identity matters here: plugins compare nodes by reference and store their own state on them. A node's info, importers, and importedModules are read live from the engine, so they stay correct after every rebuild. Nothing has to be kept in sync by hand. Writing to a node does nothing, because the engine owns the real graph.
  • A hook adapter (BundledDevHotUpdateAdapter) — every Vite plugin that has one of the two hooks gets its own wrapper, registered as that plugin's rolldown hotUpdate hook. Plugin order, and the rule that a returned module set replaces the previous one, then come from rolldown's own driver. The adapter does not re-implement them. The wrapper turns ids into nodes before it calls the Vite hook, and turns nodes back into ids after the hook returns.

The main design rules:

  • Each layer keeps its own types. The engine uses ids. Vite plugins use nodes. Translation happens only at the boundary, inside the adapter.
  • Only top-level Vite plugins are wrapped. A plugin swapped in for one environment with applyToEnvironment is a rolldown plugin. Its hotUpdate already expects ids, so it stays unwrapped.
  • One shared state per changed file, as in Vite. A pre-ordered hook opens the state for the event and expands the module set to every module of that file, so query variants such as file.vue?type=style are included. All wrappers for that file share one options object. A change made by one plugin, for example a new read function, is therefore visible to the next plugin. A post-ordered hook merges the modules buffered through moduleGraph.invalidateModule and closes the state.
  • invalidateAll rebuilds everything and reloads the page. Bundled dev has no partial invalidation to fall back on, so this is what the flag can mean here.
  • The legacy hook keeps its Vite behavior. handleHotUpdate receives the shared HmrContext with mixed module nodes, runs only for updates, and warns that it is deprecated.

Tests

End-to-end coverage lives in the rolldown repo's dev-server test suite (hmr-hot-update-hook-vite, 8 browser specs). It covers the common plugin patterns:

  • replacing the module set
  • buffering invalidateModule calls, the way the Tailwind plugin does it
  • sending a custom message with read() and hot.send, the way markdown page plugins do it
  • the legacy shared-context read chain (unocss together with plugin-vue)
  • expanding one file into its sub-modules (Vue SFC)
  • suppressing an update
  • invalidateAll

Unit tests in this repo will follow in a later PR.

@h-a-n-a
h-a-n-a changed the base branch from main to feat/client-side-hmr July 16, 2026 09:37
@h-a-n-a
h-a-n-a force-pushed the feat/client-side-hmr branch 2 times, most recently from 3082b1b to d4ba516 Compare July 21, 2026 03:49
Base automatically changed from feat/client-side-hmr to renovate/rolldown-related-dependencies July 21, 2026 13:35
Base automatically changed from renovate/rolldown-related-dependencies to main July 22, 2026 04:29
@h-a-n-a
h-a-n-a force-pushed the feat/hot-update branch 3 times, most recently from 1030097 to 362de1b Compare July 22, 2026 13:27
@h-a-n-a
h-a-n-a marked this pull request as ready for review July 23, 2026 07:12
graphite-app Bot pushed a commit to rolldown/rolldown that referenced this pull request Jul 28, 2026
Adds a dev-only `hotUpdate` plugin hook to the HMR engine. It works like Vite's hook of the same name. The Vite side is vitejs/vite#22956, which runs Vite's `hotUpdate` / `handleHotUpdate` hooks on top of this hook in bundled dev.

## Problem

When a file changes, the engine can only find modules the graph knows about: the file's own module, and modules that watched the file with `addWatchFile`. Plugins often know more. A config file may affect many modules. A content file may not be a module at all. Vite plugins express this knowledge with the `hotUpdate` hook. Without the same hook, these plugins cannot work on the rolldown dev engine.

## Design

```mermaid
flowchart LR
  A[file change] --> B[default affected set<br/>own module and transform deps]
  B --> C[hotUpdate chain<br/>each plugin may replace the set]
  C --> D[re-fetch, walk, patch<br/>existing steps, unchanged]
```

The hook edits the input of the update pipeline. It does not change how the pipeline works. It runs once per changed file, after the engine builds the default affected set, and before any module is re-fetched. Everything after that point — re-fetch, boundary walk, patch, delivery — stays the same.

The main design rules:

- **Vite behavior, engine-level types.** The chain works like Vite's: plugins run in hook order, each plugin gets the current set and may replace it, an empty array suppresses the update for this file, and no return passes the set through. But the engine uses plain module ids, not module node objects. Node objects belong to the framework layer — the adapter in vitejs/vite#22956 translates between the two.
- **The engine does not override an explicit plugin choice.** Modules returned by a hook skip the "output unchanged" check. That check is only safe when "same output" means "nothing happened". That is true for a normal edit, but not for a hook result — the change may live outside the module's code. Example: a module reads `config.json` at runtime. When a plugin maps a `config.json` edit to that module, re-running the module is the whole point, and its unchanged code proves nothing. Vite always ships hook-returned modules for the same reason.
- **Engine internals stay hidden.** Hooks never see lazy-compilation proxies or runtime modules, and cannot return them. Unknown ids are dropped with a debug log. A plugin cannot break the graph.
- **A hook cannot block error recovery.** Files queued for retry after a failed build are added before the suppress decision. So clients stuck on an error overlay always get the recovery build.
- **No hook, no cost.** If no plugin registers the hook, the whole path is skipped. If plugins return nothing, the default flow — including the "output unchanged" check — works as before.
- **No `read()` on the engine hook.** Reading the changed file from disk is a framework-layer concern — the disk content may not match what the plugin pipeline produces. Per review, `read` lives only in the Vite adapter (vitejs/vite#22956), which keeps Vite's retry behavior (vitejs/vite#610).
- **No time limit on the chain.** Same as Vite: a hook that never finishes blocks that update.

## What this enables

Through the Vite adapter, Vite plugins with either hook run on bundled dev without changes. The browser tests in this PR cover the common plugin patterns: replacing the set (tailwind-style config mapping), buffering with `moduleGraph.invalidateModule` plus an empty return, custom messages with `read()` and `hot.send` (markdown-pages style), the legacy shared-context `read` chain (unocss with plugin-vue), file-to-submodule expansion (SFC style), and `invalidateAll` as a full rebuild plus reload.

## Tests

- Unit: hidden-id filtering (runtime and lazy-proxy ids are hidden; real files and virtual modules are not).
- Browser end-to-end, six playgrounds:
  - `hmr-hot-update-hook` — the raw hook: replace, suppress, unknown ids dropped, an empty default set for a file with no module, decline, proof that a hook-selected module ships even when its output did not change, and proof that a suppressing hook cannot block error recovery.
  - `hmr-hot-update-hook-chain` — each plugin receives the set as edited by the plugins before it.
  - `hmr-hot-update-hook-delete` — delete events reach the hook; a replacement returned for a delete ships as-is.
  - `hmr-hot-update-hook-error` — a throwing hook fails the round, and the failed edit is not retried.
  - `hmr-hot-update-hook-vite` — the Vite adapter (8 specs), covering the plugin patterns listed above.
  - `hmr-delete-self-watched` — a module that watches its own file adds no extra failure mode on delete.
- A skipped engine test for #10487 (deleting a still-imported file on the raw engine): it asserts the desired behavior and is un-skipped by the resolver-cache fix.

## Not in this PR, planned next

- Directory support for `addWatchFile` (Rollup allows directories; needed to watch files that do not exist yet).
- `import.meta.glob` updates in the native plugin, built on top of that.
@h-a-n-a

h-a-n-a commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Hold this. We might need to move getModulesByFile to the Rolldown side.

shulaoda added a commit to rolldown/rolldown that referenced this pull request Oct 7, 2026
…tion via hotUpdate (#10550)

Part of #10059

## Problem

Vite's JS plugin keeps the result of a glob fresh from a `hotUpdate`
hook. Under bundled dev the native plugin replaces it, and the native
plugin only implemented `transform`.

#10549 makes the file events arrive, but nothing maps them to the module
that owns the glob.

## What this PR does

Implements `hotUpdate` in the native plugin, the way Vite's plugin does
it.

- Each `import.meta.glob` call gets one matcher in dev mode. It is built
with globstar from the absolute globs of the call, with the options Vite
passes to picomatch. `dot` follows `exhaustive`, case folding follows
`caseSensitive`, and `**/node_modules/**` is excluded unless
`exhaustive` is set.
- The glob syntax in the literal part of a path is escaped, like Vite's
`globSafeResolvedPath`.
- For a created or deleted file, the hook adds every module with a
matcher that matches the file to the affected set.
- A content edit is declined. It cannot change which files a glob
matches.

The walk in `transform` is not changed. It still matches with
`fast_glob`.

## Note

The `hotUpdate` hook is off by default (`dev.hotUpdate`, #10837), and
Vite does not pass the option yet (vitejs/vite#22956). This PR takes
effect once the option is on.

The hook is as simple as Vite's, so it inherits what the watcher and the
engine do differently from Vite:

- A file saved by renaming a temporary file over it is reported as
created, so the module of the glob is updated with it.
- A deleted directory is reported without its files, and no glob matches
a directory.
- The matchers of a deleted module stay, like in Vite, but the engine
still knows the module.

This branch has not been deployed

No deployments
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.

experimental.bundledDev: plugin hotUpdate is called without server, timestamp or read

1 participant