From 98888439e07c1dc6425deea3474330ad27b8bf33 Mon Sep 17 00:00:00 2001 From: patak <583075+patak-dev@users.noreply.github.com> Date: Thu, 14 Mar 2024 13:52:09 +0100 Subject: [PATCH] feat: formalize waitForRequestsIdle (experimental) (#16135) --- docs/guide/api-javascript.md | 12 ++ packages/vite/src/node/optimizer/index.ts | 1 - packages/vite/src/node/optimizer/optimizer.ts | 103 +++------------- packages/vite/src/node/server/index.ts | 114 ++++++++++++++++++ .../vite/src/node/server/transformRequest.ts | 5 +- playground/tailwind/vite.config.ts | 18 +++ 6 files changed, 165 insertions(+), 88 deletions(-) diff --git a/docs/guide/api-javascript.md b/docs/guide/api-javascript.md index fbfba3b7d9e2c8..0c625a4998a083 100644 --- a/docs/guide/api-javascript.md +++ b/docs/guide/api-javascript.md @@ -183,9 +183,21 @@ interface ViteDevServer { * Bind CLI shortcuts */ bindCLIShortcuts(options?: BindCLIShortcutsOptions): void + /** + * Calling `await server.waitForRequestsIdle(id)` will wait until all static imports + * are processed. If called from a load or transform plugin hook, the id needs to be + * passed as a parameter to avoid deadlocks. Calling this function after the first + * static imports section of the module graph has been processed will resolve immediately. + * @experimental + */ + waitForRequestsIdle: (ignoredId?: string) => Promise } ``` +:::info +`waitForRequestsIdle` is meant to be used as a escape hatch to improve DX for features that can't be implemented following the on-demand nature of the Vite dev server. It can be used during startup by tools like Tailwind to delay generating the app CSS classes until the app code has been seen, avoiding flashes of style changes. When this function is used in a load or transform hook, and the default HTTP1 server is used, one of the six http channels will be blocked until the server processes all static imports. Vite's dependency optimizer currently uses this function to avoid full-page reloads on missing dependencies by delaying loading of pre-bundled dependencies until all imported dependencies have been collected from static imported sources. Vite may switch to a different strategy in a future major release, setting `optimizeDeps.crawlUntilStaticImports: false` by default to avoid the performance hit in large applications during cold start. +::: + ## `build` **Type Signature:** diff --git a/packages/vite/src/node/optimizer/index.ts b/packages/vite/src/node/optimizer/index.ts index 591ea0cc8fa760..4c98a289c5077a 100644 --- a/packages/vite/src/node/optimizer/index.ts +++ b/packages/vite/src/node/optimizer/index.ts @@ -59,7 +59,6 @@ export interface DepsOptimizer { isOptimizedDepFile: (id: string) => boolean isOptimizedDepUrl: (url: string) => boolean getOptimizedDepId: (depInfo: OptimizedDepInfo) => string - delayDepsOptimizerUntil: (id: string, done: () => Promise) => void close: () => Promise diff --git a/packages/vite/src/node/optimizer/optimizer.ts b/packages/vite/src/node/optimizer/optimizer.ts index 721a2f45c8035c..096d0bef2cdd54 100644 --- a/packages/vite/src/node/optimizer/optimizer.ts +++ b/packages/vite/src/node/optimizer/optimizer.ts @@ -41,7 +41,7 @@ export function getDepsOptimizer( export async function initDepsOptimizer( config: ResolvedConfig, - server?: ViteDevServer, + server: ViteDevServer, ): Promise { if (!getDepsOptimizer(config, false)) { await createDepsOptimizer(config, server) @@ -78,7 +78,7 @@ export async function initDevSsrDepsOptimizer( async function createDepsOptimizer( config: ResolvedConfig, - server?: ViteDevServer, + server: ViteDevServer, ): Promise { const { logger } = config const ssr = false @@ -105,7 +105,6 @@ async function createDepsOptimizer( isOptimizedDepUrl: createIsOptimizedDepUrl(config), getOptimizedDepId: (depInfo: OptimizedDepInfo) => `${depInfo.file}?v=${depInfo.browserHash}`, - delayDepsOptimizerUntil, close, options, } @@ -167,9 +166,10 @@ async function createDepsOptimizer( // from the first request before resolving to minimize full page reloads. // On warm start or after the first optimization is run, we use a simpler // debounce strategy each time a new dep is discovered. - let crawlEndFinder: CrawlEndFinder | undefined + let waitingForCrawlEnd = false if (!cachedMetadata) { - crawlEndFinder = setupOnCrawlEnd(onCrawlEnd) + server._onCrawlEnd(onCrawlEnd) + waitingForCrawlEnd = true } let optimizationResult: @@ -188,7 +188,6 @@ async function createDepsOptimizer( async function close() { closed = true - crawlEndFinder?.cancel() await Promise.allSettled([ discover?.cancel(), depsOptimizer.scanProcessing, @@ -271,7 +270,7 @@ async function createDepsOptimizer( optimizationResult.result.then((result) => { // Check if the crawling of static imports has already finished. In that // case, the result is handled by the onCrawlEnd callback - if (!crawlEndFinder) return + if (!waitingForCrawlEnd) return optimizationResult = undefined // signal that we'll be using the result @@ -535,17 +534,15 @@ async function createDepsOptimizer( } function fullReload() { - if (server) { - // Cached transform results have stale imports (resolved to - // old locations) so they need to be invalidated before the page is - // reloaded. - server.moduleGraph.invalidateAll() - - server.hot.send({ - type: 'full-reload', - path: '*', - }) - } + // Cached transform results have stale imports (resolved to + // old locations) so they need to be invalidated before the page is + // reloaded. + server.moduleGraph.invalidateAll() + + server.hot.send({ + type: 'full-reload', + path: '*', + }) } async function rerun() { @@ -594,7 +591,7 @@ async function createDepsOptimizer( // we can get a list of every missing dependency before giving to the // browser a dependency that may be outdated, thus avoiding full page reloads - if (!crawlEndFinder) { + if (!waitingForCrawlEnd) { // Debounced rerun, let other missing dependencies be discovered before // the running next optimizeDeps debouncedProcessing() @@ -649,7 +646,7 @@ async function createDepsOptimizer( // be crawled if the browser requests them right away). async function onCrawlEnd() { // switch after this point to a simple debounce strategy - crawlEndFinder = undefined + waitingForCrawlEnd = false debug?.(colors.green(`✨ static imports crawl ended`)) if (closed) { @@ -757,71 +754,6 @@ async function createDepsOptimizer( debouncedProcessing(0) } } - - function delayDepsOptimizerUntil(id: string, done: () => Promise) { - if (crawlEndFinder && !depsOptimizer.isOptimizedDepFile(id)) { - crawlEndFinder.delayDepsOptimizerUntil(id, done) - } - } -} - -const callCrawlEndIfIdleAfterMs = 50 - -interface CrawlEndFinder { - delayDepsOptimizerUntil: (id: string, done: () => Promise) => void - cancel: () => void -} - -function setupOnCrawlEnd(onCrawlEnd: () => void): CrawlEndFinder { - const registeredIds = new Set() - const seenIds = new Set() - let timeoutHandle: NodeJS.Timeout | undefined - - let cancelled = false - function cancel() { - cancelled = true - } - - let crawlEndCalled = false - function callOnCrawlEnd() { - if (!cancelled && !crawlEndCalled) { - crawlEndCalled = true - onCrawlEnd() - } - } - - function delayDepsOptimizerUntil(id: string, done: () => Promise): void { - if (!seenIds.has(id)) { - seenIds.add(id) - registeredIds.add(id) - done() - .catch(() => {}) - .finally(() => markIdAsDone(id)) - } - } - function markIdAsDone(id: string): void { - registeredIds.delete(id) - checkIfCrawlEndAfterTimeout() - } - - function checkIfCrawlEndAfterTimeout() { - if (cancelled || registeredIds.size > 0) return - - if (timeoutHandle) clearTimeout(timeoutHandle) - timeoutHandle = setTimeout( - callOnCrawlEndWhenIdle, - callCrawlEndIfIdleAfterMs, - ) - } - async function callOnCrawlEndWhenIdle() { - if (cancelled || registeredIds.size > 0) return - callOnCrawlEnd() - } - - return { - delayDepsOptimizerUntil, - cancel, - } } async function createDevSsrDepsOptimizer( @@ -844,7 +776,6 @@ async function createDevSsrDepsOptimizer( // noop, there is no scanning during dev SSR // the optimizer blocks the server start run: () => {}, - delayDepsOptimizerUntil: (id: string, done: () => Promise) => {}, close: async () => {}, options: config.ssr.optimizeDeps, diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index bfc413db1a029c..8a508dbbd46c5c 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -32,6 +32,7 @@ import { isParentDirectory, mergeConfig, normalizePath, + promiseWithResolvers, resolveHostname, resolveServerUrls, } from '../utils' @@ -344,6 +345,22 @@ export interface ViteDevServer { * Open browser */ openBrowser(): void + /** + * Calling `await server.waitForRequestsIdle(id)` will wait until all static imports + * are processed. If called from a load or transform plugin hook, the id needs to be + * passed as a parameter to avoid deadlocks. Calling this function after the first + * static imports section of the module graph has been processed will resolve immediately. + * @experimental + */ + waitForRequestsIdle: (ignoredId?: string) => Promise + /** + * @internal + */ + _registerRequestProcessing: (id: string, done: () => Promise) => void + /** + * @internal + */ + _onCrawlEnd(cb: () => void): void /** * @internal */ @@ -459,6 +476,20 @@ export async function _createServer( const devHtmlTransformFn = createDevHtmlTransformFn(config) + const onCrawlEndCallbacks: (() => void)[] = [] + const crawlEndFinder = setupOnCrawlEnd(() => { + onCrawlEndCallbacks.forEach((cb) => cb()) + }) + function waitForRequestsIdle(ignoredId?: string): Promise { + return crawlEndFinder.waitForRequestsIdle(ignoredId) + } + function _registerRequestProcessing(id: string, done: () => Promise) { + crawlEndFinder.registerRequestProcessing(id, done) + } + function _onCrawlEnd(cb: () => void) { + onCrawlEndCallbacks.push(cb) + } + let server: ViteDevServer = { config, middlewares, @@ -590,6 +621,7 @@ export async function _createServer( watcher.close(), hot.close(), container.close(), + crawlEndFinder?.cancel(), getDepsOptimizer(server.config)?.close(), getDepsOptimizer(server.config, true)?.close(), closeHttpServer(), @@ -638,6 +670,10 @@ export async function _createServer( return server._restartPromise }, + waitForRequestsIdle, + _registerRequestProcessing, + _onCrawlEnd, + _setInternalServer(_server: ViteDevServer) { // Rebind internal the server variable so functions reference the user // server instance after a restart @@ -1133,3 +1169,81 @@ export async function restartServerWithUrls( server.printUrls() } } + +const callCrawlEndIfIdleAfterMs = 50 + +interface CrawlEndFinder { + registerRequestProcessing: (id: string, done: () => Promise) => void + waitForRequestsIdle: (ignoredId?: string) => Promise + cancel: () => void +} + +function setupOnCrawlEnd(onCrawlEnd: () => void): CrawlEndFinder { + const registeredIds = new Set() + const seenIds = new Set() + const onCrawlEndPromiseWithResolvers = promiseWithResolvers() + + let timeoutHandle: NodeJS.Timeout | undefined + + let cancelled = false + function cancel() { + cancelled = true + } + + let crawlEndCalled = false + function callOnCrawlEnd() { + if (!cancelled && !crawlEndCalled) { + crawlEndCalled = true + onCrawlEnd() + } + onCrawlEndPromiseWithResolvers.resolve() + } + + function registerRequestProcessing( + id: string, + done: () => Promise, + ): void { + if (!seenIds.has(id)) { + seenIds.add(id) + registeredIds.add(id) + done() + .catch(() => {}) + .finally(() => markIdAsDone(id)) + } + } + + function waitForRequestsIdle(ignoredId?: string): Promise { + if (ignoredId) { + seenIds.add(ignoredId) + markIdAsDone(ignoredId) + } + return onCrawlEndPromiseWithResolvers.promise + } + + function markIdAsDone(id: string): void { + if (registeredIds.has(id)) { + registeredIds.delete(id) + checkIfCrawlEndAfterTimeout() + } + } + + function checkIfCrawlEndAfterTimeout() { + if (cancelled || registeredIds.size > 0) return + + if (timeoutHandle) clearTimeout(timeoutHandle) + timeoutHandle = setTimeout( + callOnCrawlEndWhenIdle, + callCrawlEndIfIdleAfterMs, + ) + } + async function callOnCrawlEndWhenIdle() { + if (cancelled || registeredIds.size > 0) return + callOnCrawlEnd() + } + + return { + registerRequestProcessing, + waitForRequestsIdle, + cancel, + } +} diff --git a/packages/vite/src/node/server/transformRequest.ts b/packages/vite/src/node/server/transformRequest.ts index 2813128af5b1dd..8341f14a70a737 100644 --- a/packages/vite/src/node/server/transformRequest.ts +++ b/packages/vite/src/node/server/transformRequest.ts @@ -181,7 +181,10 @@ async function doTransform( resolved, ) - getDepsOptimizer(config, ssr)?.delayDepsOptimizerUntil(id, () => result) + const depsOptimizer = getDepsOptimizer(config, ssr) + if (!depsOptimizer?.isOptimizedDepFile(id)) { + server._registerRequestProcessing(id, () => result) + } return result } diff --git a/playground/tailwind/vite.config.ts b/playground/tailwind/vite.config.ts index 5b97ed1053e382..86521cff913e97 100644 --- a/playground/tailwind/vite.config.ts +++ b/playground/tailwind/vite.config.ts @@ -1,4 +1,21 @@ import { defineConfig } from 'vite' +import type { Plugin } from 'vite' + +function delayIndexCssPlugin(): Plugin { + let server + return { + name: 'delay-index-css', + enforce: 'pre', + configureServer(_server) { + server = _server + }, + async load(id) { + if (server && id.includes('index.css')) { + await server.waitForRequestsIdle(id) + } + }, + } +} export default defineConfig({ resolve: { @@ -25,5 +42,6 @@ export default defineConfig({ } }, }, + delayIndexCssPlugin(), ], })