Vite 7 + HMR in a WordPress Theme: What Changed from webpack-Based Themes

Key takeaways
- Vite’s dev server never bundles your app before serving it. It serves native ES modules over HTTP and transforms each file the browser actually requests, on demand, which is why
npm run devprints a ready banner in a few hundred milliseconds regardless of how large the theme gets. webpack (and the@wordpress/scriptsconfig that drives Brndle’sadmin:start/blocks:start) has to build its module graph before it can serve anything at all. - Brndle’s real
vite.config.js, confirmed directly against the current repo, wires four plugins:@tailwindcss/vite,laravel-vite-plugin, and two exports from@roots/vite-plugin,wordpressPlugin()andwordpressThemeJson(). There is no custom WebSocket code, no bespoke dev-server bridge Brndle wrote itself. - The bridge that makes a server-rendered Blade page cooperate with a client-side dev server is
laravel-vite-plugin‘srefresh: trueoption. It watchesresources/views/**(among a few other default paths) with its own file watcher and pushes a full-reload instruction over the same WebSocket Vite already opened for CSS/JS HMR. It is not Hot Module Replacement for Blade. It is an automatic browser refresh, which is a different, narrower thing. - We checked every frontend JS entry Brndle ships to Vite (
dark-mode.js,view-transitions.js,mega-menu.js,header-behaviors.js,back-to-top.js,editor.js) forimport.meta.hot.accept()calls. None exist. That means editing any of them duringnpm run devalso triggers a full browser reload, by Vite’s own default fallback behavior, not a hot swap. Only CSS genuinely hot-replaces in this theme. - None of this touches the admin settings panel or the block editor controls. Those are separate webpack bundles built through
@wordpress/scripts(admin:build/admin:start,blocks:build/blocks:start), confirmed inpackage.json, with no HMR wiring of their own.
If you’ve spent a few years building Sage 10 themes, or you’ve lived inside a standard @wordpress/scripts block project, you already have a working mental model of what a WordPress build tool’s watch mode does: you save a file, webpack recompiles the affected bundle, and you refresh the browser (or, if you wired up BrowserSync, it refreshes for you). That model is correct, as far as it goes, and it transfers cleanly to Brndle’s own admin and block-editor bundles, which still run on webpack underneath @wordpress/scripts. Where it stops transferring is the moment you run npm run dev and start editing the theme’s own CSS and JS, because that command isn’t webpack in watch mode wearing a different CLI. It’s Vite 7’s actual dev server, and Vite’s dev server does not work the way webpack’s does, architecturally, not just “faster.”
That distinction matters for a very practical reason: the behaviors you’d correctly predict from webpack experience, “every file type rebuilds the same way, and I always need a manual refresh unless I set up something extra,” don’t hold for Vite, and guessing instead of checking leads to two specific failure modes. Either you assume something is hot-replacing when it’s actually falling back to a full reload (and you lose time looking for a bug in your own code that isn’t there), or you assume a Blade edit needs the same workaround a React component would (and you go hunting for an HMR boundary that was never going to exist for a server-rendered PHP template in the first place). This post exists to replace both guesses with the actual mechanism, read directly from Brndle’s own vite.config.js and package.json in the public repository, plus how Vite’s HMR model differs from webpack’s in general, so you know which behavior to expect before you’ve saved a single file. If you haven’t gotten npm run dev running yet at all, start with Setting Up Brndle Locally: Composer, Node 20, and the First npm run dev, this post picks up exactly where that one stops.
In this guide
- Dev server architecture: what’s actually different between Vite and webpack
- How Brndle wires Vite’s dev server into a server-rendered WordPress theme
- Side-by-side: what you’d actually see editing each file type
- Troubleshooting: when HMR doesn’t do what you expect
- Questions developers ask
- Where this leaves you
Dev server architecture: what’s actually different between Vite and webpack
Start with the part that’s genuinely architectural, not a tuning difference: what each tool’s dev server is doing at the moment you type npm run dev (or, in a plain @wordpress/scripts project, wp-scripts start).
webpack builds a graph before it serves anything
webpack’s whole design, in dev mode or production, is to read your entry points, walk every import/require statement it finds, construct a complete dependency graph, run every matched file through its configured loaders (Babel or SWC for JS/JSX, PostCSS or sass-loader for styles, and so on), and emit bundled output chunks. In watch mode it keeps that graph in memory and recomputes the affected subset when a file changes, which is faster than a cold build, but the first request still has to wait for an initial full compile, because there’s nothing to serve until that bundle exists. This is true whether you’re running plain webpack, webpack-dev-server, or the webpack config @wordpress/scripts generates for you under wp-scripts start, admin:start and blocks:start included. The practical consequence developers feel directly: that first compile, and every subsequent incremental one, scales with how much code is in the graph. A project with a handful of files feels instant. A project with a sprawling admin panel or block library feels noticeably less instant, and that gap grows as the codebase grows.
Vite serves native ES modules and transforms on demand
Vite’s dev server takes a different starting position entirely: your source code is already written in a format the browser can load natively, ES modules, so instead of bundling it ahead of time, Vite’s dev server just serves it, and transforms each file only when the browser’s own module loader actually requests it. Open one page and Vite transforms the handful of modules that page imports. It doesn’t walk your entire app’s import graph up front the way webpack does, because the browser’s native import resolution is doing the graph-walking for it, one request at a time, over regular HTTP. This is the reason a Vite dev server’s startup time is largely decoupled from how big the project is: there’s no bundle to produce before the server can say “ready.” Vite’s own docs describe this directly, that native ESM lets Vite provide “rich feature enhancements” including HMR without needing to bundle first, and that cold start stays roughly constant as a project grows, which is the opposite of webpack’s scaling story.
The one piece of bundling Vite does do up front is dependency pre-bundling, converting node_modules packages that ship as CommonJS or as hundreds of tiny internal modules into a small number of flattened ESM chunks, so the browser isn’t making hundreds of waterfall requests for a single library’s internals. Vite runs that step through esbuild, a Go-based bundler, specifically because it’s dramatically faster at that one narrow job than a JavaScript-based bundler would be. That pre-bundle is cached to disk and only redone when your dependencies actually change, not on every dev server restart.
CSS HMR versus JS HMR versus “no HMR boundary at all”
This is the distinction that actually decides what you’ll see in your browser, and it’s worth being precise about it rather than treating “HMR” as one feature that’s either on or off.
CSS gets HMR essentially for free. Vite’s own features documentation states plainly that importing a .css file injects its content into the page through a <style> tag with HMR support built in. When that CSS changes, Vite swaps the content of that style tag in place. There’s no application state to preserve because there was never any JavaScript execution involved in applying a stylesheet, so this is about as close to a risk-free hot swap as a build tool can offer.
JavaScript HMR is opt-in, module by module. Vite exposes an HMR API over native ESM, import.meta.hot, and a module has to explicitly call import.meta.hot.accept(...) to tell Vite “when I change, here’s how to apply that change without reloading the page.” Framework plugins (Vite’s React plugin, the Vue plugin) wire this up automatically for components, which is why editing a React or Vue component usually feels instant and state-preserving without you writing any HMR code yourself. A plain JavaScript file with no framework plugin touching it and no accept() call has no such boundary.
No boundary means a full reload, not a silent failure. When Vite detects a changed module that no ancestor in its import chain has accepted, the update has nowhere to apply itself, so Vite’s client falls back to reloading the page outright. This is standard, documented Vite behavior, not an error state and not something specific to any one project’s setup. It’s the same category of fallback webpack’s HMR runtime uses when a module isn’t HMR-accepted, the difference is which tool is doing the graph-walking and how granular the “module” unit is in each system.
Hold those three behaviors in your head, CSS always hot-swaps, JS only hot-swaps if something explicitly opted in, anything else triggers a full reload, and the rest of this post is just applying that general rule to Brndle’s actual files.
How Brndle wires Vite’s dev server into a server-rendered WordPress theme
Everything in the previous section describes Vite in the context it was originally built for: a single-page JS app where the HTML shell is minimal and the framework owns rendering. Brndle is the opposite of that. The HTML for any given page comes from PHP, through Acorn’s Blade rendering, on the server, on every request. There is no client-side router, no virtual DOM, no JS framework deciding what’s on the page. So the interesting engineering question isn’t “how does Vite do HMR,” it’s “how does a dev server built for client-rendered apps get bridged into a theme that renders everything server-side,” and the honest answer is: through a small, specific set of off-the-shelf plugins, not a custom integration Brndle wrote itself. Here is Brndle’s actual vite.config.js, read directly from the current repository:
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite';
import laravel from 'laravel-vite-plugin'
import { wordpressPlugin, wordpressThemeJson } from '@roots/vite-plugin';
if (! process.env.APP_URL) {
process.env.APP_URL = 'http://brndle.test';
}
export default defineConfig({
base: '/wp-content/themes/brndle/public/build/',
plugins: [
tailwindcss(),
laravel({
input: [
'resources/css/app.css',
'resources/js/dark-mode.js',
'resources/js/view-transitions.js',
'resources/js/mega-menu.js',
'resources/js/header-behaviors.js',
'resources/js/back-to-top.js',
'resources/css/editor.css',
'resources/js/editor.js',
],
refresh: true,
assets: ['resources/images/**', 'resources/fonts/**'],
}),
wordpressPlugin(),
wordpressThemeJson({
disableTailwindColors: false,
disableTailwindFonts: false,
disableTailwindFontSizes: false,
disableTailwindBorderRadius: false,
}),
],
resolve: {
alias: {
'@scripts': '/resources/js',
'@styles': '/resources/css',
'@fonts': '/resources/fonts',
'@images': '/resources/images',
},
},
})
Four plugins, each doing one job. Worth going through what each one actually is, because “it’s just Vite config” undersells how much of the WordPress-specific bridging is happening inside these four lines rather than anywhere in Brndle’s own PHP or JS.
@tailwindcss/vite: Tailwind v4’s own Vite integration
Tailwind v4 ships a first-party Vite plugin rather than requiring a separate PostCSS config step, and tailwindcss() here is that plugin. It’s what lets resources/css/app.css open with a plain @import "tailwindcss" theme(static); and a set of @source directives (confirmed in the actual file: @source "../../app/**/*.php", @source "../**/*.blade.php", @source "../**/*.js") that tell Tailwind’s v4 engine which files to scan for utility class usage, PHP, Blade and JS alike, so a class you type into a Blade template gets picked up without any separate content-scanning config living outside the CSS file itself.
laravel-vite-plugin: the actual WordPress/Blade bridge
This is the plugin doing the real architectural work, and it’s worth separating what it does into the two genuinely different jobs it performs, because conflating them is exactly where the “but I thought this had HMR” confusion starts.
Job one: asset URL switching. In production, Brndle’s PHP asset-enqueue code reads a manifest file that vite build produces and points <link>/<script> tags at the hashed files under public/build. While npm run dev is running, laravel-vite-plugin‘s helper functions detect that the dev server is active and swap those same tags to point at http://localhost:5173 (or whatever port Vite actually bound) instead. The browser then loads Brndle’s CSS and JS straight from Vite’s dev server and opens the Vite client’s WebSocket connection, the same transport CSS and JS HMR updates travel over. This detection is what makes “leave npm run dev running in a tab and just edit files” work at all for a theme whose HTML never passes through Vite, Vite doesn’t need to render the page; it only needs its script tag to end up in the page PHP already rendered.
Job two: the refresh: true bridge, which is a full-reload watcher, not Blade HMR. Brndle’s config passes refresh: true with no custom path array, which means laravel-vite-plugin applies its own default watch list. Reading that default directly from the plugin’s source: app/View/Components/**, resources/views/**, resources/lang/**, lang/**, and routes/**. resources/views/** is exactly where Brndle’s Blade templates live. What this means in practice: laravel-vite-plugin runs its own independent file watcher (unrelated to Vite’s module graph) over those paths, and the instant a matching file changes, it sends a full-reload command over the same WebSocket connection the dev server already has open to your browser. Your browser reloads itself, automatically, without you touching the refresh button, the moment you save a Blade file. That’s a genuinely useful convenience. It is not Hot Module Replacement. Nothing about this swaps content in place or preserves page state, and nothing about it talks to Acorn’s Blade compiler or its compiled-view cache at all, that’s an entirely separate, already-documented mechanism on the PHP side, covered in full in Why Your Blade Edits Aren’t Showing Up: Acorn’s View Cache, Explained. The two systems happen to cooperate (Vite tells the browser to reload; separately, Acorn’s own mtime check recompiles the view so the reload actually shows something new) but neither one is aware the other exists.
@roots/vite-plugin‘s two exports: wordpressPlugin() and wordpressThemeJson()
wordpressPlugin() solves a different problem from either of the above: it lets Vite-bundled code import from WordPress’s own JS packages, the @wordpress/* namespace, without actually bundling those packages into the output. Brndle’s own editor.js, read directly from the repo, is a real, if minimal, example of this in production use:
import domReady from '@wordpress/dom-ready';
domReady(() => {
//
});
Without wordpressPlugin(), Vite would try to resolve @wordpress/dom-ready as an ordinary npm package and bundle its source into the output. wordpressPlugin() rewrites that import at build time into a reference to the global wp.domReady that WordPress’s own script-loading system already makes available on the page, the same global-script pattern @wordpress/scripts‘s webpack config uses for the admin and block-editor bundles via wp-scripts‘s dependency extraction, applied here on Vite’s side of the build instead. It also generates a dependency manifest (an editor.deps.json-style file) so Brndle’s PHP enqueue code knows which WordPress script handles to declare as dependencies, and it extends Vite’s HMR specifically for the block editor’s iframed canvas, which is why editor.css/editor.js are in the laravel() plugin’s input array alongside the frontend assets rather than being handled by the separate blocks:build/blocks:start webpack pipeline that compiles the actual block registration JS.
wordpressThemeJson() runs the other direction: it reads Tailwind’s v4 configuration, colors, font families, font sizes, border radius, confirmed by the four boolean flags in Brndle’s config (disableTailwindColors, disableTailwindFonts, disableTailwindFontSizes, disableTailwindBorderRadius, all left false, meaning all four stay enabled) and turns that into the theme.json settings WordPress’s block editor reads to populate its own color swatches, font pickers and spacing controls. It’s how a Tailwind utility class and a block editor’s native color picker end up describing the same design tokens instead of drifting into two separate systems.
None of these four plugins reach into Acorn, into WordPress’s PHP request lifecycle, or into the Blade compiler. The entire WordPress-awareness of this dev server lives in off-the-shelf packages maintained outside Brndle’s own repo, which is worth knowing both because it means Brndle isn’t carrying bespoke, unmaintainable glue code, and because it means the behavior described here applies to any Acorn-based theme using the same plugin stack, Sage and Radicle included, not something unique to Brndle.
Side-by-side: what you’d actually see editing each file type
With npm run dev running in one terminal tab and a Brndle page open in your browser, here’s what happens for each category of file, derived directly from the mechanisms above rather than guessed at.
Editing resources/css/app.css (or any Tailwind utility inside a .blade.php file)
Change a value directly in app.css, a custom property, a block-scoped CSS file under resources/css/blocks/, anything that ultimately feeds the compiled stylesheet, and Vite’s built-in CSS HMR applies: the <style> tag’s content gets swapped in the browser, no reload, scroll position and any open menu or form state untouched. This is true even when the change originates from editing a Tailwind class name inside a .blade.php file, because Tailwind v4’s @source scanning treats that Blade file as a content source for class detection; the CSS output changes as a result, and that CSS change is what actually propagates. The catch: because resources/views/** is also in laravel-vite-plugin‘s default refresh-watch list, saving that same Blade file also fires the full-reload signal described above, at essentially the same moment. In practice you won’t perceive the CSS hot-swap as a separate event, the page reloads anyway, and the reload happens to already have the freshly generated CSS available. The hot-swap-without-reload experience is really only visible when you edit a stylesheet directly, something that isn’t also a path the refresh watcher matches.
Editing resources/js/dark-mode.js, mega-menu.js, or any of the other frontend JS entries
These five files (plus view-transitions.js, header-behaviors.js, back-to-top.js) are plain vanilla JavaScript, no framework, no HMR plugin wired up for them, and a direct check of each file’s source confirms zero calls to import.meta.hot.accept() anywhere. That means there is no HMR boundary for Vite to apply an in-place update to. Save a change to mega-menu.js and Vite’s client, finding no accepting ancestor in that module’s chain, falls back to its default behavior: a full page reload. You’ll see the exact same outcome as the Blade case above, a reload, just triggered through Vite’s own HMR-propagation fallback rather than laravel-vite-plugin‘s separate file watcher. Functionally identical from where you’re sitting; mechanically, two different systems arriving at the same decision independently.
Editing a Blade template under resources/views/
Two independent things happen, and it’s worth knowing both exist even though you’ll only consciously notice one. First, on the PHP side, Acorn’s Blade compiler checks the file’s modification time against its compiled cache in wp-content/cache/acorn/framework/views/ and recompiles automatically if your edit is newer, the mechanism covered in full in the companion post on Acorn’s view cache. Second, and entirely separately, laravel-vite-plugin‘s file watcher sees the same save event against its resources/views/** default path and fires a full-reload instruction over the WebSocket. The two don’t coordinate with each other in any way, they just both happen to be watching overlapping territory for unrelated reasons, PHP correctness in Acorn’s case, convenience in Vite’s. What you experience: save the file, and your browser reloads itself automatically a moment later, no manual refresh needed, showing the newly compiled template, assuming Acorn’s own mtime check succeeded, which it normally does on a local dev filesystem. If a Blade edit doesn’t show up even after that automatic reload, the dev-server bridge isn’t the thing to debug, the view cache post’s symptom checklist is.
Editing files under admin/src or blocks/src
None of the above applies at all. These two directories are compiled by @wordpress/scripts‘s webpack config, invoked as npm run admin:start and npm run blocks:start, entirely separate processes from npm run dev. They don’t appear anywhere in vite.config.js‘s input array, Vite’s dev server has no awareness of them, and laravel-vite-plugin‘s refresh watcher doesn’t cover their source paths either. Editing a file here and expecting Vite’s dev server (running in a different tab) to do anything is the single most common category error for a developer new to this split. The correct workflow is running the matching webpack watch command in its own terminal tab, then doing an ordinary browser refresh for the admin settings panel or a Gutenberg editor reload for a block’s editor controls, exactly the workflow a Sage 10 or @wordpress/scripts background already trained you to expect, because that part of Brndle genuinely is the same webpack-based system you’re used to.
Troubleshooting: when HMR doesn’t do what you expect
Most “HMR isn’t working” reports against an Acorn-based Vite setup trace back to one of a short list of causes. Work through these before assuming something’s broken in the config itself.
The page is loading production assets, not the dev server
If your edits do nothing at all, not even a reload, check whether the page’s CSS/JS is actually being served from localhost:5173 rather than public/build. Open your browser’s network tab and look at where the stylesheet and script tags point. If they’re pointing at hashed files in public/build, the theme’s asset helper isn’t detecting the dev server, which usually means npm run dev isn’t actually running, crashed silently, or the APP_URL environment value doesn’t match the URL you’re loading the site at closely enough for the detection to succeed. Brndle’s config defaults APP_URL to http://brndle.test when it isn’t set; if your local site uses a different hostname, set APP_URL to match it before starting npm run dev.
Vite bound to a different port than you expect
Vite’s default dev-server port is 5173. If something else already holds that port, Vite will pick the next free one and print the actual port in its startup banner, rather than failing outright. Read that banner line rather than assuming 5173; if the theme’s asset-detection code or a hardcoded reference somewhere still points at the old port, that mismatch alone will make HMR appear completely dead.
A browser extension or proxy is blocking the WebSocket
Every HMR update, CSS swap, full-reload signal, all of it, travels over one WebSocket connection between the Vite client script and the dev server. An ad blocker, a corporate proxy, or a local HTTPS proxy sitting in front of your dev URL can silently drop that connection while the rest of the page loads fine. Symptom: the page loads normally from the dev server (so you know detection worked), but nothing ever updates no matter what you edit, and no reload ever fires for a Blade change either. Check the browser console for a WebSocket connection error or a repeating reconnect attempt; if you see one, test in a plain browser profile with extensions disabled before touching anything in the config.
You’re editing a file that isn’t in the input array at all
Vite only watches what it’s been told is an entry point, directly or through an import chain starting from one. Brndle’s input array lists exactly eight files. Add a brand-new JS file under resources/js/ without importing it from one of the existing entries or adding it to that array yourself, and Vite has no reason to know it exists, so edits to it do nothing, correctly, by design, not as a bug.
Expecting admin or block-editor changes to show up without running the matching webpack watcher
Covered above, but worth restating as a troubleshooting step on its own: if you’re only running npm run dev and editing admin/src or blocks/src, nothing will happen, because that command never touches those directories. Start npm run admin:start or npm run blocks:start in their own terminal tabs for those specific changes.
A reload fires, but the content still looks old
This is the dev-server bridge working correctly and a completely different system failing: Acorn’s own compiled-view cache not invalidating, usually because of OPcache ignoring timestamp changes, or an unreliable mtime on a container-mounted filesystem. The fix lives in the Acorn view cache post, not in anything Vite-related, wp acorn view:clear or the blunter rm -rf wp-content/cache/acorn/framework/views/*.
Questions developers ask
Does Vite’s HMR ever apply directly to a Blade file the way it applies to a Vue or React component?
No, and it architecturally can’t. Blade files render to HTML entirely on the server; there’s no client-side module instance for Vite’s HMR API to patch in place, the way there is for a mounted JS component. The closest Brndle gets is the automatic full-reload laravel-vite-plugin fires when a watched path changes, which saves you a manual refresh but is not the same mechanism as true HMR.
Why does editing CSS feel instant but editing JS doesn’t, even though both are “in” the Vite pipeline?
Because Vite’s CSS handling has built-in HMR support with no opt-in required, injected via a <style> tag that can be swapped wholesale, while JS HMR requires an explicit import.meta.hot.accept() call that none of Brndle’s frontend scripts make. Both are watched by the same dev server; only one of the two file types gets a true hot swap without extra code.
Could Brndle’s JS files be made to hot-swap instead of full-reloading?
In principle, yes, a module can add its own import.meta.hot.accept() handler describing how to re-apply itself without a reload. In practice, most of Brndle’s frontend scripts (the mega menu, header behaviors, dark mode toggle) attach event listeners to the DOM on load and guard themselves with an init flag to stay idempotent; writing a correct accept handler for that pattern means explicitly tearing down the old listeners first, which is real additional code for a marginal benefit on scripts this small. Brndle’s own repo doesn’t do this today, and a full reload on a handful of lightweight vanilla-JS files is a reasonable tradeoff against that complexity.
Is the “refresh” behavior the same thing people mean by live reload in older tooling?
Functionally, yes, it’s a full browser reload triggered automatically by a file change, which is exactly what “live reload” meant before HMR existed as a separate, more granular concept. The distinction worth keeping is that Vite’s dev server supports both in the same session: true HMR for CSS (and any JS that opts in), and this coarser live-reload behavior for everything laravel-vite-plugin‘s watcher is told to cover.
Does this refresh watcher have anything to do with WordPress’s own template hierarchy or hook system?
No. It’s a plain file-system watcher (built on chokidar under the hood, the same library most Node file-watching tools use) looking at a fixed list of glob paths. It has no concept of WordPress hooks, the template hierarchy, or which block is rendering what; it only knows that a file at a path it’s watching changed, and reacts by telling the browser to reload.
If I set refresh to a custom array instead of true, do I lose the defaults?
Yes. Passing refresh: true is what tells laravel-vite-plugin to apply its own default path list internally; passing your own array (or array of path/config objects) replaces that default entirely rather than adding to it. If you ever customize this in a child theme or a fork, you’d need to include resources/views/** yourself to keep Blade-triggered reloads working, since it isn’t implicitly preserved alongside a custom list.
Why does the admin panel use webpack at all if Vite is already set up in this theme?
@wordpress/scripts is the standard, WordPress-maintained tool for building block-editor and admin-facing React, and it comes with webpack under the hood as a fixed part of its own tooling, not a choice Brndle made independently for that half of the codebase. Rather than fighting that convention or maintaining a second, custom Vite config to replicate what @wordpress/scripts already does well (dependency extraction against WordPress globals, consistent with core and every other block plugin), Brndle runs both toolchains side by side, Vite for the frontend theme assets where its dev-server speed and CSS HMR pay off most, webpack through @wordpress/scripts for the parts of the WordPress ecosystem that already standardize on it.
Does Vite 7 change anything about this compared to earlier Vite major versions?
The dev-server architecture described in this post, native ESM serving, esbuild dependency pre-bundling, the HMR API and its full-reload fallback, has been Vite’s core model since its early releases and isn’t new to version 7 specifically. What’s confirmed as current in Brndle’s own package.json is the pinned major version, "vite": "^7.0.0", alongside "laravel-vite-plugin": "^2.0.0" and "@roots/vite-plugin": "^1.0.0"; this post describes the architecture those packages implement today, not a changelog of what changed between Vite majors.
Will a page cache plugin or Cloudflare interfere with any of this during development?
It can, and it’s an easy thing to overlook because it has nothing to do with Vite directly. A page cache serving a fully rendered HTML response can mean the page your browser is looking at was never re-rendered by PHP at all, Blade edit or not, which makes a working dev-server reload look like it “did nothing.” If you’re running any page caching locally for parity with production, disable it or exclude your dev domain while you’re actively editing.
Where this leaves you
The webpack mental model isn’t wrong, it’s just scoped to half of Brndle’s toolchain now. Everything you already know about watch mode, manual refresh, and “rebuild then reload” still applies exactly as-is to the admin panel and block editor, because that half is still webpack, driven by @wordpress/scripts, unchanged from what a Sage 10 or standard block-plugin background already taught you. The other half, the frontend theme assets under npm run dev, runs on a dev server built around a different assumption: don’t bundle until something’s actually requested, and hot-swap whatever can be hot-swapped without asking you to write extra code for it. CSS gets that hot-swap for free. The handful of vanilla JS files Brndle ships don’t, because none of them declare an HMR boundary, so they fall back to a full reload exactly the way Vite’s own documented behavior says they should. Blade templates get a different kind of automatic reload entirely, one wired by laravel-vite-plugin‘s file watcher rather than anything Vite’s module graph is doing, triggered by the same four plugins read straight out of vite.config.js rather than any bespoke bridge code. None of it is magic, and none of it requires memorizing a new framework, it’s four well-documented, off-the-shelf packages, doing exactly what their own documentation says they do, pointed at the specific files a WordPress theme happens to have.