Avoid full reload in @tailwindcss/vite when editing a scanned file that is not a loaded module - #20323
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughThe Vite plugin now detects external JavaScript and TypeScript files during hot updates and skips the full-reload path for them. A new integration test covers a scanned but unloaded module, captures HMR payloads, verifies an update instead of a full reload, and checks regenerated CSS. The changelog documents the fix under the unreleased changes. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Confidence Score: 5/5This looks safe to merge.
Reviews (1): Last reviewed commit: "Add changelog entry" | Re-trigger Greptile |
|
Hey! Closed this in favor of #20336 because while the regex does solve some things, it doesn't solve the full picture (e.g. a file with a custom extension that isn't loaded yet) |
…s as modules (tailwindlabs#20336) This PR fixes an issue where editing a scanned file that Vite (or one of its plugins) can process as a module, but that isn't currently loaded, caused `@tailwindcss/vite` to force a full page reload, throwing away all client state. The `hotUpdate` hook has a fallback that sends a `full-reload` for files that Tailwind scans but that Vite knows nothing about (e.g. `.php` or `.blade.php` templates rendered by a backend). Without it, edits to those files wouldn't refresh the page at all. To detect those files we check whether every module for the changed file is an `asset` and/or has no id, because the scanner's `addWatchFile` calls create exactly such placeholder nodes for every scanned file. The problem is that a source file that Vite _can_ process, but that isn't loaded yet, looks exactly the same. The realistic way to get into that state is code splitting: with route-level splitting (e.g. `React.lazy`, TanStack Router's `autoCodeSplitting`, lazy routes in `vue-router`), every component behind an un-visited split boundary only exists as a scan placeholder in the module graph. Editing any of them reloaded the whole app. The same happens for component stylesheets that a framework plugin compiles into the component (e.g. Angular via Analog), which never show up as their own module. A full reload is never useful for these files: if the file is loaded, Vite's own HMR handles it, and if it isn't loaded, reloading the page won't load it either. Any new candidates still apply through the regular `css-update` flow because the file is registered via `addWatchFile`. So instead, we now skip the fallback when the changed file is handled by Vite's module pipeline: - The file exists as a real module in another environment (e.g. an SSR-only module). This check already existed and is folded into the same code path. - The file is part of the JS/TS or CSS families, which Vite transforms natively. - For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an SSG plugin), a file with the same extension exists as a real module in some environment's module graph, then a plugin does handle this file type and the changed file just isn't loaded (yet). External templates like `.php` files still trigger a full reload exactly like before. Fixes: tailwindlabs#20320 Fixes: tailwindlabs#19903 Closes: tailwindlabs#20323 ## Test plan 1. Added integration tests to ensure extensions handled by default rely on HMR 2. Added integration tests to make sure that unknown extensions that have been handled already will also use HMR 3. Manually tested that changing a `.php` file still triggers a `full-reload` 4. Manually tested the reproduction where local client state isn't thrown away <img width="594" height="100" alt="file-14a86a90a1e4b810c2b80338ea688572" src="https://github.com/user-attachments/assets/a4507502-4a5d-43ee-93b9-14c793a25891" /> <img width="1122" height="1376" alt="file-a5121da2ad77b95fdd1703560ef1ff41" src="https://github.com/user-attachments/assets/cc5c67b0-b5ad-481f-8823-a3266d75357d" />
Summary
Fixes #20320
In dev mode,
@tailwindcss/vite'shotUpdatehook has a fallback that sends afull-reloadfor files that Tailwind scans but that aren't handled by Vite or any plugin (e.g..phpor.htmltemplates). Without it, edits to those files wouldn't refresh the page at all.However, the check ("every module for this file is an
assetand/or has no id") also matches JS/TS source files that Tailwind has scanned but that Vite hasn't loaded as a module yet — lazy routes, unvisited chunks, and so on. The scanner'saddWatchFilecall creates an asset-type module-graph node withid === undefinedfor every scanned file, and for a not-yet-loaded.tsxthere is no real JS node next to it, so the fallback fires and the whole page reloads, throwing away all client state. In apps with route-level code splitting this happens on almost every edit to a not-currently-loaded component.This change exempts JS/TS module files (
.js,.jsx,.ts,.tsx,.mjs,.cjs,.mts,.cts) from the fallback. Vite handles these files itself whenever they are actually loaded, and for scanned-but-unloaded files any new candidates still flow through the regular CSS update, so nothing is lost by skipping the reload:.tsfile now results in a targeted{"type":"update"}for the Tailwind stylesheet instead of{"type":"full-reload"}, and the generated CSS includes the new candidates..phpfile still triggers a full reload as before.Test plan
src/unloaded.tsfile that is scanned but never imported. A small fixture plugin wrapsenvironment.hot.sendto log every HMR payload. The test editsunloaded.tsand asserts that anupdatepayload is sent (not afull-reload) and that the stylesheet regenerates with the new candidate.HOT_SEND {"type":"full-reload"}; after the change it logsHOT_SEND {"type":"update", ...}for/src/index.cssand the CSS contains the new candidate. Editing a scanned.phpfile still logsHOT_SEND {"type":"full-reload"}.