From 4c46b5b642ea3790d01f9926a8e9aa0cc0207eb9 Mon Sep 17 00:00:00 2001 From: Hana Date: Thu, 17 Sep 2026 14:49:29 +0800 Subject: [PATCH 1/3] fix(bundled-dev): serve an already-built lazy route instead of recompiling it on reload Claude-Session: https://claude.ai/code/session_01P1jv1ZxTBDDWFXbV6YA3Hm --- packages/vite/src/node/server/bundledDev.ts | 28 +++++++ .../server/middlewares/triggerLazyBundling.ts | 7 ++ .../__tests__/lazy-compilation.spec.ts | 79 ++++++++++++++++++- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index 3519f188c1044f..67c294c04c7ae4 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -58,6 +58,8 @@ export class MemoryFiles { } } +const LAZY_PROXY_QUERY = '?rolldown-lazy=1' + export class BundledDev { private _devEngine!: DevEngine private viteRuntime?: string @@ -106,6 +108,8 @@ export class BundledDev { private pendingPayloadFilenames = new Set() + private builtDynamicEntries = new Map() + get hasBuildOutput(): boolean { return ( this.memoryFiles.size > 1 || @@ -235,6 +239,16 @@ export class BundledDev { this.lastBuildError = null this.storeOutputFiles(result.output) + this.builtDynamicEntries.clear() + for (const file of result.output) { + if ( + file.type === 'chunk' && + file.isDynamicEntry && + file.facadeModuleId + ) { + this.builtDynamicEntries.set(file.facadeModuleId, file.fileName) + } + } // Trigger a full reload if there's no error in the result and a reload is pending from HMR. if (this.fullReloadPending) { @@ -352,6 +366,19 @@ export class BundledDev { } } + /** + * Nothing while a rebuild is pending, so an edit is never answered with the + * chunk it made stale. + */ + async builtLazyChunk(proxyModuleId: string): Promise { + if (!this.initialBuildCompleted) return + const state = await this.devEngine.getBundleState() + if (state.hasStaleOutput || state.lastBuildErrored) return + return this.builtDynamicEntries.get( + proxyModuleId.replace(LAZY_PROXY_QUERY, ''), + ) + } + /** * Called when the client reports that it evaluated a payload (the line appended * by `payloadDeliveredAck`). Only then is the payload recorded on the server's @@ -369,6 +396,7 @@ export class BundledDev { async close(): Promise { this._closed = true this.memoryFiles.clear() + this.builtDynamicEntries.clear() await this._devEngine?.close() this.initialBuildCompleted = false } diff --git a/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts index 7c56aa65e1c92a..d4c757be9a1def 100644 --- a/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts +++ b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import type { Connect } from '#dep-types/connect' import type { ViteDevServer } from '..' @@ -26,6 +27,12 @@ export function triggerLazyBundlingMiddleware( const moduleId = params.get('id') const clientId = params.get('clientId') + const builtChunk = moduleId && (await bundledDev.builtLazyChunk(moduleId)) + if (builtChunk) { + res.statusCode = 302 + res.setHeader('Location', path.posix.join(server.config.base, builtChunk)) + return res.end() + } let result: { code: string; filename: string } | undefined try { result = await bundledDev.triggerLazyBundling(moduleId, clientId) diff --git a/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts b/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts index 3b0dd27caeff7c..b93125efb7722b 100644 --- a/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts +++ b/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts @@ -1,6 +1,15 @@ +import path from 'node:path' import type { Response, Route } from 'playwright-chromium' import { beforeAll, describe, expect, test } from 'vitest' -import { isServe, page, promiseWithResolvers } from '~utils' +import { + browser, + editFile, + isServe, + page, + promiseWithResolvers, + viteServer, + viteTestUrl, +} from '~utils' // Regression test for rolldown/rolldown#10774. // @@ -16,9 +25,9 @@ import { isServe, page, promiseWithResolvers } from '~utils' // response is fetched from the server (so the server finished writing it) but // held back from the page until B has been requested and evaluated. // -// Both tests share one page session on purpose. A lazy entry is served through +// Both tests share one page session on purpose. A lazy entry is compiled by // `/@vite/lazy` only until it is first fetched on this server; a later page -// load gets it as an ordinary chunk. So the second test cannot reload — it +// load is redirected to its built chunk. So the second test cannot reload — it // continues from the state the first test leaves behind. const factoryFor = (file: string) => @@ -112,3 +121,67 @@ describe.runIf(isServe)('lazy compilation', () => { expect(bodyC).not.toMatch(factoryFor('shared.js')) }) }) + +const bundledDev = () => viteServer.environments.client.bundledDev as any + +const rebuildLanded = async () => { + const state = await bundledDev().devEngine.getBundleState() + return !state.hasStaleOutput +} + +const builtChunkFor = (file: string): string | undefined => + bundledDev().builtDynamicEntries.get(path.join(viteServer.config.root, file)) + +describe.runIf(isServe)('lazy compilation: reload after fetch', () => { + test('a reload after the first fetch loads the route from the built chunk', async () => { + await expect.poll(rebuildLanded).toBe(true) + + const lazyStatuses: number[] = [] + const chunkRequests: string[] = [] + page.on('response', (res) => { + if (res.url().includes('/@vite/lazy?')) lazyStatuses.push(res.status()) + }) + page.on('request', (req) => { + if (/\/assets\/page-a-.*\.js$/.test(req.url())) + chunkRequests.push(req.url()) + }) + + await page.reload() + await page.click('#route-a-btn') + await expect + .poll(() => page.textContent('#route-a-content')) + .toBe('A:shared-value') + + expect(lazyStatuses).toEqual([302]) + expect(chunkRequests).toHaveLength(1) + }) + + test('a page that loaded before an edit gets the edited route', async () => { + const context = await browser.newContext() + const other = await context.newPage() + await other.goto(viteTestUrl) + await expect + .poll(() => other.textContent('#route-b-content')) + .toBe('pending') + + // Wait until the server has seen the edit, so the click is not racing + // the watcher. Nothing has run B since the reload, so no rebuild follows; + // the output just turns stale and the click compiles from source. + await expect.poll(rebuildLanded).toBe(true) + const before = builtChunkFor('page-b.js') + expect(before).toBeDefined() + editFile('page-b.js', (code) => code.replace('B:', 'B-edited:')) + await expect + .poll( + async () => + !(await rebuildLanded()) || builtChunkFor('page-b.js') !== before, + ) + .toBe(true) + + await other.click('#route-b-btn') + await expect + .poll(() => other.textContent('#route-b-content')) + .toBe('B-edited:shared-value') + await context.close() + }) +}) From ffd188bb87bf9020c92e2c32d3f4cf28a9cadb66 Mon Sep 17 00:00:00 2001 From: Hana Date: Thu, 17 Sep 2026 18:07:34 +0800 Subject: [PATCH 2/3] fix(bundled-dev): redirect a lazy request to the built chunk only when it is safe A full-build chunk runs and re-registers every module in it, so a client that already holds one of those modules from a lazy payload would run it twice. The redirect is now allowed only for a client that took no lazy payload and saw no full build since it connected. The bundle state is read after the running build finished, because an edit marks the output stale only when its HMR task ends. Before, a request during that task was answered with the pre-edit chunk. Also: the redirect lookup is inside the middleware's try/catch, and the proxy stubs are left out of the chunk map. Claude-Session: https://claude.ai/code/session_01P1jv1ZxTBDDWFXbV6YA3Hm --- packages/vite/src/node/server/bundledDev.ts | 45 ++++++-- .../server/middlewares/triggerLazyBundling.ts | 18 ++-- .../__tests__/lazy-compilation.spec.ts | 101 +++++++++++++++++- playground/lazy-compilation/shared.js | 3 + playground/lazy-compilation/vite.config.ts | 17 ++- 5 files changed, 165 insertions(+), 19 deletions(-) diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index 67c294c04c7ae4..d6899938fc45fb 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -108,7 +108,10 @@ export class BundledDev { private pendingPayloadFilenames = new Set() - private builtDynamicEntries = new Map() + private builtLazyChunks = new Map() + private buildGeneration = 0 + private clientBuildGeneration = new Map() + private clientsWithLazyPayloads = new Set() get hasBuildOutput(): boolean { return ( @@ -139,6 +142,7 @@ export class BundledDev { 'vite:client-connected', async (payload, client) => { this.clients.setupIfNeeded(client, payload.clientId) + this.clientBuildGeneration.set(payload.clientId, this.buildGeneration) this.devEngine.registerClient(payload.clientId) }, ) @@ -161,6 +165,8 @@ export class BundledDev { if (clientId) { this.devEngine.removeClient(clientId) this.reloadNeededClientIds.delete(clientId) + this.clientBuildGeneration.delete(clientId) + this.clientsWithLazyPayloads.delete(clientId) } }) this.environment.hot.on( @@ -239,14 +245,17 @@ export class BundledDev { this.lastBuildError = null this.storeOutputFiles(result.output) - this.builtDynamicEntries.clear() + this.buildGeneration++ + this.builtLazyChunks.clear() for (const file of result.output) { if ( file.type === 'chunk' && file.isDynamicEntry && - file.facadeModuleId + file.facadeModuleId && + // the stub of a never-fetched route is a dynamic entry too + !file.facadeModuleId.endsWith(LAZY_PROXY_QUERY) ) { - this.builtDynamicEntries.set(file.facadeModuleId, file.fileName) + this.builtLazyChunks.set(file.facadeModuleId, file.fileName) } } @@ -358,6 +367,7 @@ export class BundledDev { debug?.( `TRIGGER-LAZY: trigger lazy bundling for module ${moduleId} for client ${clientId}`, ) + this.clientsWithLazyPayloads.add(clientId) const result = await this.devEngine.compileEntry(moduleId, clientId) this.pendingPayloadFilenames.add(result.filename) return { @@ -367,15 +377,28 @@ export class BundledDev { } /** - * Nothing while a rebuild is pending, so an edit is never answered with the - * chunk it made stale. + * A full-build chunk runs and re-registers every module in it, unlike a + * lazy payload whose factories skip modules the client already holds. So a + * client gets a chunk only while it took no lazy payload and saw no full + * build since it connected. + * + * The build state is read after the running build finished, because an + * edit marks the output stale only when its build task ends. */ - async builtLazyChunk(proxyModuleId: string): Promise { - if (!this.initialBuildCompleted) return + async builtLazyChunk( + proxyModuleId: string, + clientId: string, + ): Promise { + if (this._closed || !this.initialBuildCompleted) return + if (!proxyModuleId.endsWith(LAZY_PROXY_QUERY)) return + if (this.clientsWithLazyPayloads.has(clientId)) return + await this.devEngine.ensureCurrentBuildFinish() const state = await this.devEngine.getBundleState() if (state.hasStaleOutput || state.lastBuildErrored) return - return this.builtDynamicEntries.get( - proxyModuleId.replace(LAZY_PROXY_QUERY, ''), + if (this.clientBuildGeneration.get(clientId) !== this.buildGeneration) + return + return this.builtLazyChunks.get( + proxyModuleId.slice(0, -LAZY_PROXY_QUERY.length), ) } @@ -396,7 +419,7 @@ export class BundledDev { async close(): Promise { this._closed = true this.memoryFiles.clear() - this.builtDynamicEntries.clear() + this.builtLazyChunks.clear() await this._devEngine?.close() this.initialBuildCompleted = false } diff --git a/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts index d4c757be9a1def..15e740ebf3cb6c 100644 --- a/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts +++ b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts @@ -27,14 +27,20 @@ export function triggerLazyBundlingMiddleware( const moduleId = params.get('id') const clientId = params.get('clientId') - const builtChunk = moduleId && (await bundledDev.builtLazyChunk(moduleId)) - if (builtChunk) { - res.statusCode = 302 - res.setHeader('Location', path.posix.join(server.config.base, builtChunk)) - return res.end() - } let result: { code: string; filename: string } | undefined try { + const builtChunk = + moduleId && + clientId && + (await bundledDev.builtLazyChunk(moduleId, clientId)) + if (builtChunk) { + res.statusCode = 302 + res.setHeader( + 'Location', + path.posix.join(server.config.base, builtChunk), + ) + return res.end() + } result = await bundledDev.triggerLazyBundling(moduleId, clientId) } catch (e) { server.config.logger.error( diff --git a/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts b/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts index b93125efb7722b..a9fe10d64772bb 100644 --- a/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts +++ b/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts @@ -130,7 +130,10 @@ const rebuildLanded = async () => { } const builtChunkFor = (file: string): string | undefined => - bundledDev().builtDynamicEntries.get(path.join(viteServer.config.root, file)) + bundledDev().builtLazyChunks.get(path.join(viteServer.config.root, file)) + +const sharedRuns = (p: typeof page) => + p.evaluate(() => (globalThis as any).__sharedRuns as number) describe.runIf(isServe)('lazy compilation: reload after fetch', () => { test('a reload after the first fetch loads the route from the built chunk', async () => { @@ -184,4 +187,100 @@ describe.runIf(isServe)('lazy compilation: reload after fetch', () => { .toBe('B-edited:shared-value') await context.close() }) + + test('a page that took a lazy payload keeps taking them, so shared.js runs once', async () => { + // With no client that ran A, an edit to A leaves the output stale + // instead of rebuilding it. + await page.reload() + const context = await browser.newContext() + const other = await context.newPage() + try { + await other.goto(viteTestUrl) + await expect + .poll(() => other.textContent('#route-a-content')) + .toBe('pending') + + await expect.poll(rebuildLanded).toBe(true) + editFile('page-a.js', (code) => code.replace('A:', 'A-edited:')) + await expect.poll(async () => !(await rebuildLanded())).toBe(true) + await other.click('#route-a-btn') + await expect + .poll(() => other.textContent('#route-a-content')) + .toBe('A-edited:shared-value') + await expect.poll(rebuildLanded).toBe(true) + expect(builtChunkFor('page-b.js')).toBeDefined() + + // A built chunk for B would run shared.js a second time + await other.click('#route-b-btn') + await expect + .poll(() => other.textContent('#route-b-content')) + .toBe('B-edited:shared-value') + expect(await sharedRuns(other)).toBe(1) + } finally { + await context.close() + } + }) + + test('a click while the edit is still being processed gets the edited route', async () => { + // The output turns stale only when the HMR task for the edit ends. The + // `hold-page-b` plugin keeps that task inside `transform`. + const holdPageB = viteServer.config.plugins.find( + (p) => p.name === 'hold-page-b', + )!.api as { hold: Promise | null; holding: boolean } + const taskHold = promiseWithResolvers() + // A redirected chunk is held until the page has seen the update, so the + // update cannot repair the page. + const chunkHold = promiseWithResolvers() + // The page has not run B, so it never fetches the patch; the update + // message is the only trace. + const updateReceived = promiseWithResolvers() + + await expect.poll(rebuildLanded).toBe(true) + const context = await browser.newContext() + const held = await context.newPage() + try { + held.on('websocket', (ws) => { + ws.on('framereceived', (frame) => { + if (String(frame.payload).includes('bundled-dev-update')) { + updateReceived.resolve() + } + }) + }) + await held.goto(viteTestUrl) + await expect + .poll(() => held.textContent('#route-b-content')) + .toBe('pending') + await expect.poll(rebuildLanded).toBe(true) + expect(builtChunkFor('page-b.js')).toBeDefined() + await held.route('**/assets/page-b-*.js', async (route) => { + await chunkHold.promise + await route.continue() + }) + + holdPageB.hold = taskHold.promise + editFile('page-b.js', (code) => + code.replace('B-edited:', 'B-edited-twice:'), + ) + await expect.poll(() => holdPageB.holding).toBe(true) + + const lazyRequest = held.waitForRequest((req) => + req.url().includes('/@vite/lazy?'), + ) + await held.click('#route-b-btn') + await lazyRequest + + taskHold.resolve() + await updateReceived.promise + chunkHold.resolve() + + await expect + .poll(() => held.textContent('#route-b-content')) + .toBe('B-edited-twice:shared-value') + } finally { + holdPageB.hold = null + taskHold.resolve() + chunkHold.resolve() + await context.close() + } + }) }) diff --git a/playground/lazy-compilation/shared.js b/playground/lazy-compilation/shared.js index 811ddbf86b183f..3148eb7d6670ab 100644 --- a/playground/lazy-compilation/shared.js +++ b/playground/lazy-compilation/shared.js @@ -1 +1,4 @@ +// how many times this body ran in the page; the specs read it back +globalThis.__sharedRuns = (globalThis.__sharedRuns ?? 0) + 1 + export const shared = 'shared-value' diff --git a/playground/lazy-compilation/vite.config.ts b/playground/lazy-compilation/vite.config.ts index 194c91676dd316..cef768431336b9 100644 --- a/playground/lazy-compilation/vite.config.ts +++ b/playground/lazy-compilation/vite.config.ts @@ -1,8 +1,23 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' + +// Lets a spec hold the HMR task for page-b.js inside `transform`. +export const holdPageB = { hold: null as Promise | null, holding: false } + +const holdPageBPlugin: Plugin = { + name: 'hold-page-b', + api: holdPageB, + async transform(_code, id) { + if (!id.endsWith('/page-b.js') || !holdPageB.hold) return + holdPageB.holding = true + await holdPageB.hold + holdPageB.holding = false + }, +} // Lazy compilation only exists in bundled dev, so this playground always runs // with it on, also under plain `pnpm test-serve`. export default defineConfig({ + plugins: [holdPageBPlugin], experimental: { bundledDev: true, }, From 4e220fbddf22acddf69f13cede1c07c9dfbd4698 Mon Sep 17 00:00:00 2001 From: Hana Date: Fri, 18 Sep 2026 14:56:20 +0800 Subject: [PATCH 3/3] test(lazy-compilation): look up built chunks by normalized id The map is keyed by rolldown's module id, which keeps backslashes on Windows. Claude-Session: https://claude.ai/code/session_01P1jv1ZxTBDDWFXbV6YA3Hm --- .../__tests__/lazy-compilation.spec.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts b/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts index a9fe10d64772bb..d96e1d74bd6cec 100644 --- a/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts +++ b/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts @@ -1,5 +1,6 @@ import path from 'node:path' import type { Response, Route } from 'playwright-chromium' +import { normalizePath } from 'vite' import { beforeAll, describe, expect, test } from 'vitest' import { browser, @@ -129,8 +130,16 @@ const rebuildLanded = async () => { return !state.hasStaleOutput } -const builtChunkFor = (file: string): string | undefined => - bundledDev().builtLazyChunks.get(path.join(viteServer.config.root, file)) +// the map is keyed by rolldown's module id, which keeps backslashes on Windows +const builtChunkFor = (file: string): string | undefined => { + const id = path.posix.join(viteServer.config.root, file) + for (const [key, chunk] of bundledDev().builtLazyChunks as Map< + string, + string + >) { + if (normalizePath(key) === id) return chunk + } +} const sharedRuns = (p: typeof page) => p.evaluate(() => (globalThis as any).__sharedRuns as number)