diff --git a/.changeset/quiet-vite-preloads.md b/.changeset/quiet-vite-preloads.md new file mode 100644 index 00000000000..ad9d6814711 --- /dev/null +++ b/.changeset/quiet-vite-preloads.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: restore client bundles and chunk splitting with Vite 8 diff --git a/packages/qwik-vite/src/plugins/plugin.ts b/packages/qwik-vite/src/plugins/plugin.ts index d60ecccc5ae..f8aa4fe148d 100644 --- a/packages/qwik-vite/src/plugins/plugin.ts +++ b/packages/qwik-vite/src/plugins/plugin.ts @@ -453,6 +453,18 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { preserveSignature: 'allow-extension', }); } + const handlers = await _ctx.resolve('@qwik.dev/core/handlers.mjs', undefined, { + skipSelf: true, + }); + if (handlers) { + _ctx.emitFile({ + id: handlers.id, + type: 'chunk', + // allow-extension folds the QRL symbol exports into the qwik-core chunk + preserveSignature: 'allow-extension', + }); + shouldAddHandlers = false; + } } }; @@ -758,6 +770,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { ctx.emitFile({ id: key.id, type: 'chunk', + // allow-extension folds the QRL symbol exports into the qwik-core chunk preserveSignature: 'allow-extension', }); } @@ -1368,7 +1381,8 @@ export const isDev = ${JSON.stringify(isDev)}; return 'qwik-preloader'; } else if ( // likewise, core and handlers have to be in the same chunk so there's no import waterfall - /[/\\](core|qwik)[/\\](handlers|dist[/\\]core(\.prod|\.min)?)\.[cm]js$/.test(id) + QWIK_HANDLERS_MODULE_RE.test(id) || + /[/\\](core|qwik)[/\\]dist[/\\]core(\.prod|\.min)?\.[cm]js$/.test(id) ) { return 'qwik-core'; } else if (/[/\\](core|qwik)[/\\]dist[/\\]qwikloader\.js$/.test(id)) { @@ -1392,7 +1406,10 @@ export const isDev = ${JSON.stringify(isDev)}; if (chunkName) { // we group related segments together based on their common entry or Qwik Insights provided hash // This not only applies to source files, but also qwik libraries files that are imported through node_modules - return chunkName; + return chunkName + .replace(/^(\.\.[/\\])+/, '') + .replace(/^[/\\]+/, '') + .replace(/[/\\]/g, '-'); } } } @@ -1541,6 +1558,9 @@ export const QWIK_PRELOADER_ID = '@qwik.dev/core/preloader'; /** @internal virtual import to ensure the _run etc handlers are exported as-is */ export const QWIK_HANDLERS_ID = '@qwik-handlers'; +/** @internal matches the handlers entry module emitted for client builds */ +export const QWIK_HANDLERS_MODULE_RE = /[/\\](core|qwik)[/\\]handlers\.[cm]js$/; + export const SRC_DIR_DEFAULT = 'src'; export const CLIENT_OUT_DIR = 'dist'; diff --git a/packages/qwik-vite/src/plugins/plugin.unit.ts b/packages/qwik-vite/src/plugins/plugin.unit.ts index 2a0cc9ec0ae..0adbd622cd8 100644 --- a/packages/qwik-vite/src/plugins/plugin.unit.ts +++ b/packages/qwik-vite/src/plugins/plugin.unit.ts @@ -96,6 +96,47 @@ test('debug true', async () => { assert.deepEqual(opts.debug, true); }); +test('emits loader and handlers entry chunks for client builds', async () => { + const plugin = await mockPlugin(); + const emitted: [string, string][] = []; + await plugin.buildStart({ + resolve: async (id: string) => ({ id: `/resolved/${id}` }), + emitFile: ({ id, preserveSignature }: { id: string; preserveSignature: string }) => + emitted.push([id, preserveSignature]), + } as any); + + // allow-extension folds the handlers exports into the qwik-core chunk (no facade, no waterfall) + expect(emitted).toEqual([ + ['/resolved/@qwik.dev/core/qwikloader.js', 'allow-extension'], + ['/resolved/@qwik.dev/core/handlers.mjs', 'allow-extension'], + ]); +}); + +test('groups handlers with core so rollup avoids an import waterfall', async () => { + const plugin = await mockPlugin(); + + expect(plugin.manualChunks('/project/packages/qwik/handlers.mjs', {} as any)).toBe('qwik-core'); + expect(plugin.manualChunks('/project/packages/qwik/dist/core.mjs', {} as any)).toBe('qwik-core'); +}); + +test('sanitizes relative manual chunk names', async () => { + const plugin = await mockPlugin(); + await plugin.normalizeOptions({ entryStrategy: { type: 'smart' } }); + const chunkName = plugin.manualChunks('/project/segment.js', { + getModuleInfo: () => ({ + meta: { + segment: { + ctxName: 'component$', + hash: 'hash', + entry: '../../packages/router/segment', + }, + }, + }), + } as any); + + expect(chunkName).toBe('packages-router-segment'); +}); + test('csr', async () => { const plugin = await mockPlugin(); const opts = await plugin.normalizeOptions({ csr: true }); diff --git a/packages/qwik-vite/src/plugins/rollup.ts b/packages/qwik-vite/src/plugins/rollup.ts index 8408ea73a5f..4f71fdd0a70 100644 --- a/packages/qwik-vite/src/plugins/rollup.ts +++ b/packages/qwik-vite/src/plugins/rollup.ts @@ -10,6 +10,7 @@ import type { } from '../types'; import { createQwikPlugin, + QWIK_HANDLERS_MODULE_RE, type ExperimentalFeatures, type NormalizedQwikPluginOptions, type QwikBuildMode, @@ -25,6 +26,20 @@ type QwikRollupPluginApi = { getOptions: () => NormalizedQwikPluginOptions; }; +type ManualChunkFn = ( + id: string, + meta: { getModuleInfo: Rollup.GetModuleInfo } +) => string | void | null; + +type RolldownOutputOptions = Rollup.OutputOptions & { + codeSplitting?: { + includeDependenciesRecursively: boolean; + groups: Array<{ + name: (id: string, context: { getModuleInfo: Rollup.GetModuleInfo }) => string | void | null; + }>; + }; +}; + /** @public */ export function qwikRollup(qwikRollupOpts: QwikRollupPluginOptions = {}): any { const qwikPlugin = createQwikPlugin(qwikRollupOpts.optimizerOptions); @@ -192,6 +207,22 @@ const getChunkFileName = ( } }; +/** Vite 8+ bundles with Rolldown, which needs different output options than Rollup. */ +export async function isRolldownVite(optimizer: Optimizer): Promise { + const vitePkgJsonPath = await findDepPkgJsonPath(optimizer.sys, 'vite', optimizer.sys.cwd()); + if (!vitePkgJsonPath) { + return false; + } + try { + const fs: typeof import('fs') = await optimizer.sys.dynamicImport('node:fs'); + const vitePkgJson = JSON.parse(await fs.promises.readFile(vitePkgJsonPath, 'utf-8')); + return parseInt(String(vitePkgJson?.version), 10) >= 8; + } catch { + // Keep Rollup-compatible output when Vite cannot be detected. + return false; + } +} + export async function normalizeRollupOutputOptionsObject( qwikPlugin: QwikPlugin, rollupOutputOptsObj: Rollup.OutputOptions | undefined, @@ -227,9 +258,6 @@ export async function normalizeRollupOutputOptionsObject( if (prevManualChunks && typeof prevManualChunks !== 'function') { throw new Error('manualChunks must be a function'); } - // Casts bridge Rollup vs Rolldown ManualChunkMeta type differences - type ManualChunkFn = (id: string, meta: unknown) => string | void | null; - // We need custom chunking for the client build outputOpts.manualChunks = prevManualChunks ? (id, meta) => @@ -256,8 +284,32 @@ export async function normalizeRollupOutputOptionsObject( */ outputOpts.hoistTransitiveImports = false; + const usesRolldown = await isRolldownVite(optimizer); + if (usesRolldown && outputOpts.manualChunks) { + const manualChunks = outputOpts.manualChunks as ManualChunkFn; + const rolldownOutput = outputOpts as RolldownOutputOptions; + rolldownOutput.codeSplitting = { + includeDependenciesRecursively: false, + groups: [ + { + // explicit types: rolldown ships its own codeSplitting types that break inference + name: (id: string, context: { getModuleInfo: Rollup.GetModuleInfo }) => + // grouping the handlers entry would empty its facade under rolldown + QWIK_HANDLERS_MODULE_RE.test(id) + ? null + : (manualChunks(id, { + getModuleInfo: context.getModuleInfo.bind(context), + }) ?? null), + }, + ], + }; + delete outputOpts.manualChunks; + } + // V2 official release TODO: remove below checks and just keep `outputOpts.onlyExplicitManualChunks = true;` - const userPkgJsonPath = await findDepPkgJsonPath(optimizer.sys, 'rollup', optimizer.sys.cwd()); + const userPkgJsonPath = usesRolldown + ? undefined + : await findDepPkgJsonPath(optimizer.sys, 'rollup', optimizer.sys.cwd()); if (userPkgJsonPath) { try { const fs: typeof import('fs') = await optimizer.sys.dynamicImport('node:fs'); diff --git a/packages/qwik-vite/src/plugins/rollup.unit.ts b/packages/qwik-vite/src/plugins/rollup.unit.ts index a0c97a59a4c..32dbff9ce8c 100644 --- a/packages/qwik-vite/src/plugins/rollup.unit.ts +++ b/packages/qwik-vite/src/plugins/rollup.unit.ts @@ -1,3 +1,5 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path, { resolve } from 'node:path'; import { qwikRollup } from './rollup'; import type { Rollup } from 'vite'; @@ -8,10 +10,10 @@ import { normalizePath } from '../../../qwik/src/testing/util'; const cwd = process.cwd(); -function mockOptimizerOptions(): OptimizerOptions { +function mockOptimizerOptions(rootDir = process.cwd()): OptimizerOptions { return { sys: { - cwd: () => process.cwd(), + cwd: () => rootDir, env: 'node', os: process.platform, dynamicImport: async (path) => import(path), @@ -83,6 +85,43 @@ test('rollup default output options, client', async () => { assert.deepEqual(rollupOutputOpts.format, 'es'); }); +test('uses explicit Rolldown code splitting with Vite 8', async () => { + const rootDir = await mkdtemp(resolve(tmpdir(), 'qwik-vite-')); + try { + const viteDir = resolve(rootDir, 'node_modules', 'vite'); + await mkdir(viteDir, { recursive: true }); + await writeFile(resolve(viteDir, 'package.json'), JSON.stringify({ version: '8.0.0' })); + + const plugin = qwikRollup({ optimizerOptions: mockOptimizerOptions(rootDir) }); + await plugin.options!({}); + const output = (await plugin.outputOptions!({ + manualChunks: () => 'manual', + })) as Rollup.OutputOptions & { + onlyExplicitManualChunks?: boolean; + codeSplitting: { + includeDependenciesRecursively: boolean; + groups: Array<{ name: (id: string, context: object) => string | void | null }>; + }; + }; + + assert.isUndefined(output.manualChunks); + assert.isUndefined(output.onlyExplicitManualChunks); + assert.isFalse(output.codeSplitting.includeDependenciesRecursively); + assert.equal( + output.codeSplitting.groups[0].name('module', { getModuleInfo: () => null }), + 'manual' + ); + // grouping the handlers entry would empty its facade under rolldown + assert.isNull( + output.codeSplitting.groups[0].name('/project/packages/qwik/handlers.mjs', { + getModuleInfo: () => null, + }) + ); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); + test('rollup default output options, ssr', async () => { const initOpts = { optimizerOptions: mockOptimizerOptions(), diff --git a/packages/qwik-vite/src/plugins/vite.ts b/packages/qwik-vite/src/plugins/vite.ts index 7a7f168d322..8f0110e263f 100644 --- a/packages/qwik-vite/src/plugins/vite.ts +++ b/packages/qwik-vite/src/plugins/vite.ts @@ -35,7 +35,7 @@ import { type QwikPluginDevTools, type QwikPluginOptions, } from './plugin'; -import { createRollupError, normalizeRollupOutputOptions } from './rollup'; +import { createRollupError, isRolldownVite, normalizeRollupOutputOptions } from './rollup'; import { isVirtualId } from './vite-utils'; import { emitQwikWorkerCoreChunk, @@ -337,7 +337,10 @@ export function qwikVite(qwikViteOpts: QwikVitePluginOptions = {}): any { qwikPlugin, viteConfig.build?.rollupOptions?.output ), - preserveEntrySignatures: 'exports-only', + // rolldown requires allow-extension with explicit code splitting; exports-only keeps rollup chunks lean + preserveEntrySignatures: (await isRolldownVite(qwikPlugin.getOptimizer())) + ? 'allow-extension' + : 'exports-only', onwarn: (warning, warn) => { if (warning.plugin === 'typescript' && warning.message.includes('outputToFilesystem')) { return; @@ -437,13 +440,14 @@ export function qwikVite(qwikViteOpts: QwikVitePluginOptions = {}): any { !qwikViteOpts.csr && qwikPlugin.getOptions().target === 'client' ) { - const names = ['vite:build-import-analysis']; const plugins = config.plugins as VitePlugin[]; - for (const name of names) { - const i = plugins.findIndex((p) => p?.name === name); - if (i >= 0) { - plugins.splice(i, 1); - } + const nativeIndex = plugins.findIndex((p) => p?.name === 'native:import-analysis-build'); + const preloadIndex = + nativeIndex >= 0 + ? nativeIndex + : plugins.findIndex((p) => p?.name === 'vite:build-import-analysis'); + if (preloadIndex >= 0) { + plugins.splice(preloadIndex, 1); } } }, diff --git a/packages/qwik-vite/src/plugins/vite.unit.ts b/packages/qwik-vite/src/plugins/vite.unit.ts index 1f07446e63b..c96e47182ef 100644 --- a/packages/qwik-vite/src/plugins/vite.unit.ts +++ b/packages/qwik-vite/src/plugins/vite.unit.ts @@ -1,3 +1,5 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path, { resolve } from 'node:path'; import type { Rollup } from 'vite'; import { assert, describe, test } from 'vitest'; @@ -14,6 +16,7 @@ import { rewriteWorkerCorePlaceholders, rewriteWorkerQrlChunkPlaceholders, } from './worker-qrl-chunks'; +import { emitQwikWorkerCoreChunk, QWIK_WORKER_CORE_ID } from './worker-core'; const cwd = process.cwd(); @@ -32,10 +35,13 @@ const chunkInfoMocks = [ }, ] as Rollup.PreRenderedChunk[]; -function mockOptimizerOptions(env: 'node' | 'deno' = 'node'): OptimizerOptions { +function mockOptimizerOptions( + env: 'node' | 'deno' = 'node', + rootDir = process.cwd() +): OptimizerOptions { return { sys: { - cwd: () => process.cwd(), + cwd: () => rootDir, env, os: process.platform, dynamicImport: async (path) => import(path), @@ -210,6 +216,91 @@ test('command: build, mode: development', async () => { assert.deepEqual(c.ssr?.noExternal, noExternal); }); +test('removes Vite 8 native preload transforms from client builds', async () => { + const plugin = getPlugin({ optimizerOptions: mockOptimizerOptions() }); + await plugin.config.call(configHookPluginContext, {}, { command: 'build', mode: 'production' }); + const plugins = [ + { name: 'vite:build-import-analysis' }, + { name: 'native:import-analysis-build' }, + { name: 'keep' }, + ]; + + await plugin.configResolved({ base: '/', build: {}, plugins } as any); + + assert.deepEqual( + plugins.map((plugin) => plugin.name), + ['vite:build-import-analysis', 'keep'] + ); +}); + +async function makeRootWithVite(viteMajorVersion: number) { + const rootDir = await mkdtemp(resolve(tmpdir(), 'qwik-vite-')); + const viteDir = resolve(rootDir, 'node_modules', 'vite'); + await mkdir(viteDir, { recursive: true }); + await writeFile( + resolve(viteDir, 'package.json'), + JSON.stringify({ version: `${viteMajorVersion}.0.0` }) + ); + return rootDir; +} + +test('emits the worker core facade with a strict signature', () => { + const emitted: unknown[] = []; + emitQwikWorkerCoreChunk({ emitFile: (file: unknown) => emitted.push(file) } as any); + // strict keeps the facade its own chunk so the sentinel rewrite can find it + assert.deepEqual(emitted, [ + { + id: QWIK_WORKER_CORE_ID, + name: 'qwik-worker-core', + type: 'chunk', + preserveSignature: 'strict', + }, + ]); +}); + +test('keeps exports-only entry signatures with Rollup-based Vite', async () => { + const rootDir = await makeRootWithVite(7); + try { + const plugin = getPlugin({ optimizerOptions: mockOptimizerOptions('node', rootDir) }); + const c = (await plugin.config.call( + configHookPluginContext, + {}, + { command: 'build', mode: 'production' } + ))!; + assert.deepEqual(c.build!.rollupOptions!.preserveEntrySignatures, 'exports-only'); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); + +test('uses allow-extension entry signatures with Vite 8', async () => { + const rootDir = await makeRootWithVite(8); + try { + const plugin = getPlugin({ optimizerOptions: mockOptimizerOptions('node', rootDir) }); + const c = (await plugin.config.call( + configHookPluginContext, + {}, + { command: 'build', mode: 'production' } + ))!; + assert.deepEqual(c.build!.rollupOptions!.preserveEntrySignatures, 'allow-extension'); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); + +test('removes legacy Vite preload transforms from client builds', async () => { + const plugin = getPlugin({ optimizerOptions: mockOptimizerOptions() }); + await plugin.config.call(configHookPluginContext, {}, { command: 'build', mode: 'production' }); + const plugins = [{ name: 'vite:build-import-analysis' }, { name: 'keep' }]; + + await plugin.configResolved({ base: '/', build: {}, plugins } as any); + + assert.deepEqual( + plugins.map((plugin) => plugin.name), + ['keep'] + ); +}); + test('command: build, mode: production', async () => { const initOpts = { optimizerOptions: mockOptimizerOptions(), diff --git a/packages/qwik-vite/src/plugins/worker-core.ts b/packages/qwik-vite/src/plugins/worker-core.ts index 2fa4a6323b1..d05e564b6b5 100644 --- a/packages/qwik-vite/src/plugins/worker-core.ts +++ b/packages/qwik-vite/src/plugins/worker-core.ts @@ -32,7 +32,8 @@ export const emitQwikWorkerCoreChunk = (ctx: Rollup.PluginContext) => { id: QWIK_WORKER_CORE_ID, name: 'qwik-worker-core', type: 'chunk', - preserveSignature: 'allow-extension', + // strict stops rollup merging this facade away, which hides it from the sentinel rewrite + preserveSignature: 'strict', }); };