Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-vite-preloads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/core': patch
---

fix: restore client bundles and chunk splitting with Vite 8
24 changes: 22 additions & 2 deletions packages/qwik-vite/src/plugins/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
};

Expand Down Expand Up @@ -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',
});
}
Expand Down Expand Up @@ -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)) {
Expand All @@ -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, '-');
}
}
}
Expand Down Expand Up @@ -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';
Expand Down
41 changes: 41 additions & 0 deletions packages/qwik-vite/src/plugins/plugin.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
60 changes: 56 additions & 4 deletions packages/qwik-vite/src/plugins/rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '../types';
import {
createQwikPlugin,
QWIK_HANDLERS_MODULE_RE,
type ExperimentalFeatures,
type NormalizedQwikPluginOptions,
type QwikBuildMode,
Expand All @@ -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);
Expand Down Expand Up @@ -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<boolean> {
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,
Expand Down Expand Up @@ -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) =>
Expand All @@ -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');
Expand Down
43 changes: 41 additions & 2 deletions packages/qwik-vite/src/plugins/rollup.unit.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down
20 changes: 12 additions & 8 deletions packages/qwik-vite/src/plugins/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
},
Expand Down
Loading
Loading