diff --git a/.changeset/wet-sheep-call.md b/.changeset/wet-sheep-call.md new file mode 100644 index 00000000000..48105ec44b6 --- /dev/null +++ b/.changeset/wet-sheep-call.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Vite: Improve performance of dev server requests by invalidating Remix's virtual modules on relevant file changes rather than on every request diff --git a/integration/vite-route-added-test.ts b/integration/vite-route-added-test.ts new file mode 100644 index 00000000000..10d9b4f5872 --- /dev/null +++ b/integration/vite-route-added-test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { test, expect } from "@playwright/test"; +import getPort from "get-port"; + +import { createProject, viteDev, VITE_CONFIG } from "./helpers/vite.js"; + +const files = { + "app/routes/_index.tsx": String.raw` + import { useState, useEffect } from "react"; + import { Link } from "@remix-run/react"; + + export default function IndexRoute() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( +

Mounted: {mounted ? "yes" : "no"}

+ ); + } + `, +}; + +test.describe(async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject({ + "vite.config.js": await VITE_CONFIG({ port }), + ...files, + }); + stop = await viteDev({ cwd, port }); + }); + test.afterAll(async () => await stop()); + + test("Vite / dev / route added", async ({ page }) => { + let pageErrors: Error[] = []; + page.on("pageerror", (error) => pageErrors.push(error)); + + // wait for hydration to make sure initial virtual modules are loaded + await page.goto(`http://localhost:${port}/`, { waitUntil: "networkidle" }); + await expect(page.locator("[data-mounted]")).toHaveText("Mounted: yes"); + + // add new route file + await fs.writeFile( + path.join(cwd, "app/routes/new.tsx"), + String.raw` + export default function Route() { + return ( +
new route
+ ); + } + `, + "utf-8" + ); + + // client is not notified of new route addition (https://github.com/remix-run/remix/issues/7894) + // however server can handle new route + await expect + .poll(async () => { + await page.goto(`http://localhost:${port}/new`); + return page.getByText("new route").isVisible(); + }) + .toBe(true); + }); +}); diff --git a/integration/vite-manifest-invalidation-test.ts b/integration/vite-route-exports-modified-offscreen-test.ts similarity index 97% rename from integration/vite-manifest-invalidation-test.ts rename to integration/vite-route-exports-modified-offscreen-test.ts index 9a17574c809..77aae316793 100644 --- a/integration/vite-manifest-invalidation-test.ts +++ b/integration/vite-route-exports-modified-offscreen-test.ts @@ -56,7 +56,7 @@ test.describe(async () => { }); test.afterAll(() => stop()); - test("Vite / dev / invalidate manifest on route exports change", async ({ + test("Vite / dev / route exports modified offscreen", async ({ page, context, browserName, diff --git a/packages/remix-dev/vite/plugin.ts b/packages/remix-dev/vite/plugin.ts index 55058dc774b..1ffc8b40cdb 100644 --- a/packages/remix-dev/vite/plugin.ts +++ b/packages/remix-dev/vite/plugin.ts @@ -352,7 +352,10 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { | { isSsrBuild: true; getManifest: () => Promise }; let viteChildCompiler: Vite.ViteDevServer | null = null; - let cachedPluginConfig: ResolvedRemixVitePluginConfig | undefined; + + // This is initialized during `config` hook, so most of the code can assume this is defined without null check. + // During dev, this is updated on config file change or route file addition/removal. + let pluginConfig: ResolvedRemixVitePluginConfig; let resolveServerBuildConfig = (): ServerBuildConfig | null => { if ( @@ -448,8 +451,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { }; let getServerEntry = async () => { - let pluginConfig = await resolvePluginConfig(); - return ` import * as entryServer from ${JSON.stringify( resolveFileUrl(pluginConfig, pluginConfig.entryServerFilePath) @@ -502,8 +503,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { }; let createBuildManifest = async (): Promise => { - let pluginConfig = await resolvePluginConfig(); - let viteManifest = await loadViteManifest( pluginConfig.assetsBuildDirectory ); @@ -569,7 +568,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { }; let getDevManifest = async (): Promise => { - let pluginConfig = await resolvePluginConfig(); let routes: Manifest["routes"] = {}; let routeManifestExports = await getRouteManifestModuleExports( @@ -625,8 +623,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { viteUserConfig = _viteUserConfig; viteCommand = viteConfigEnv.command; - let pluginConfig = await resolvePluginConfig(); - cachedPluginConfig = pluginConfig; + pluginConfig = await resolvePluginConfig(); Object.assign( process.env, @@ -816,11 +813,10 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { } if (id.endsWith(CLIENT_ROUTE_QUERY_STRING)) { - invariant(cachedPluginConfig); let routeModuleId = id.replace(CLIENT_ROUTE_QUERY_STRING, ""); let sourceExports = await getRouteModuleExports( viteChildCompiler, - cachedPluginConfig, + pluginConfig, routeModuleId ); @@ -865,10 +861,9 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { // Give the request handler access to the critical CSS in dev to avoid a // flash of unstyled content since Vite injects CSS file contents via JS getCriticalCss: async (build, url) => { - invariant(cachedPluginConfig); return getStylesForUrl( viteDevServer, - cachedPluginConfig, + pluginConfig, cssModulesManifest, build, url @@ -883,32 +878,32 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { }, }); - // We cache the pluginConfig here to make sure we're only invalidating virtual modules when necessary. - // This requires a separate cache from `cachedPluginConfig`, which is updated by remix-hmr-updates. If - // we shared the cache, it would already be refreshed by remix-hmr-updates at this point, and we'd - // have no way of comparing against the cache to know if the virtual modules need to be invalidated. - let previousPluginConfig: ResolvedRemixVitePluginConfig | undefined; + // Invalidate virtual modules and update cached plugin config via file watcher + viteDevServer.watcher.on("all", async (eventName, filepath) => { + let { normalizePath } = importViteEsmSync(); - return () => { - viteDevServer.middlewares.use(async (_req, _res, next) => { - try { - let pluginConfig = await resolvePluginConfig(); + let appFileAddedOrRemoved = + (eventName === "add" || eventName === "unlink") && + normalizePath(filepath).startsWith( + normalizePath(pluginConfig.appDirectory) + ); - if ( - JSON.stringify(pluginConfig) !== - JSON.stringify(previousPluginConfig) - ) { - previousPluginConfig = pluginConfig; + invariant(viteConfig?.configFile); + let viteConfigChanged = + eventName === "change" && + normalizePath(filepath) === normalizePath(viteConfig.configFile); - invalidateVirtualModules(viteDevServer); - } + if (appFileAddedOrRemoved || viteConfigChanged) { + let lastPluginConfig = pluginConfig; + pluginConfig = await resolvePluginConfig(); - next(); - } catch (error) { - next(error); + if (!isEqualJson(lastPluginConfig, pluginConfig)) { + invalidateVirtualModules(viteDevServer); } - }); + } + }); + return () => { // Let user servers handle SSR requests in middleware mode, // otherwise the Vite plugin will handle the request if (!viteDevServer.config.server.middlewareMode) { @@ -938,7 +933,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { return; } - invariant(cachedPluginConfig); invariant(viteConfig); let { @@ -946,7 +940,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { serverBuildDirectory, serverBuildFile, rootDirectory, - } = cachedPluginConfig; + } = pluginConfig; let ssrViteManifest = await loadViteManifest(serverBuildDirectory); let clientViteManifest = await loadViteManifest(assetsBuildDirectory); @@ -1007,7 +1001,7 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { ); } - if (cachedPluginConfig.isSpaMode) { + if (pluginConfig.isSpaMode) { await handleSpaMode( path.join(rootDirectory, serverBuildDirectory), serverBuildFile, @@ -1078,7 +1072,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { } let vite = importViteEsmSync(); - let pluginConfig = await resolvePluginConfig(); let importerShort = vite.normalizePath( path.relative(pluginConfig.rootDirectory, importer) ); @@ -1145,8 +1138,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { async transform(code, id, options) { if (options?.ssr) return; - let pluginConfig = cachedPluginConfig || (await resolvePluginConfig()); - let route = getRoute(pluginConfig, id); if (!route) return; @@ -1296,9 +1287,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { if (!useFastRefresh) return; if (id.endsWith(CLIENT_ROUTE_QUERY_STRING)) { - let pluginConfig = - cachedPluginConfig || (await resolvePluginConfig()); - return { code: addRefreshWrapper(pluginConfig, code, id) }; } @@ -1317,8 +1305,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { code = result.code!; let refreshContentRE = /\$Refresh(?:Reg|Sig)\$\(/; if (refreshContentRE.test(code)) { - let pluginConfig = - cachedPluginConfig || (await resolvePluginConfig()); code = addRefreshWrapper(pluginConfig, code, id); } return { code, map: result.map }; @@ -1327,9 +1313,6 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { { name: "remix-hmr-updates", async handleHotUpdate({ server, file, modules, read }) { - let pluginConfig = await resolvePluginConfig(); - // Update the config cache any time there is a file change - cachedPluginConfig = pluginConfig; let route = getRoute(pluginConfig, file); type ManifestRoute = Manifest["routes"][string]; @@ -1379,6 +1362,10 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => { ]; }; +function isEqualJson(v1: unknown, v2: unknown) { + return JSON.stringify(v1) === JSON.stringify(v2); +} + function addRefreshWrapper( pluginConfig: ResolvedRemixVitePluginConfig, code: string,