diff --git a/packages/vite/helpers/global-defines.spec.ts b/packages/vite/helpers/global-defines.spec.ts index 117378a6a2..d0960ff05a 100644 --- a/packages/vite/helpers/global-defines.spec.ts +++ b/packages/vite/helpers/global-defines.spec.ts @@ -1,6 +1,23 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; -import { getGlobalDefines, isHmrProgressOverlayEnabled } from './global-defines.js'; +import { getGlobalDefines, getUserDefineEntries, isHmrProgressOverlayEnabled, setUserDefineEntries } from './global-defines.js'; + +describe('setUserDefineEntries / getUserDefineEntries', () => { + afterEach(() => setUserDefineEntries(undefined)); + + it('captures __FOO__ keys as [key, expression] pairs, JSON-encoding non-string values', () => { + setUserDefineEntries({ __VUE_OPTIONS_API__: true, __APP_VERSION__: '"1.2.3"', 'process.env.FOO': '"bar"', 'global.isIOS': 'true', __not_a_define: '1' }); + expect(getUserDefineEntries()).toEqual([ + ['__VUE_OPTIONS_API__', 'true'], + ['__APP_VERSION__', '"1.2.3"'], + ]); + }); + + it('is empty when no config has been captured', () => { + setUserDefineEntries(undefined); + expect(getUserDefineEntries()).toEqual([]); + }); +}); describe('isHmrProgressOverlayEnabled (NS_VITE_PROGRESS_OVERLAY)', () => { it('defaults to enabled when the env var is unset', () => { diff --git a/packages/vite/helpers/global-defines.ts b/packages/vite/helpers/global-defines.ts index a34efdfd26..cabfb84d83 100644 --- a/packages/vite/helpers/global-defines.ts +++ b/packages/vite/helpers/global-defines.ts @@ -215,6 +215,11 @@ export function setUserDefineEntries(define: Record | undefined userProcessEnvDefineEntries = envEntries; } +/** The captured `__FOO__` define entries as `[key, expression]` pairs (see setUserDefineEntries). */ +export function getUserDefineEntries(): ReadonlyArray<[string, string]> { + return userDefineEntries; +} + /** The captured `process.env.` define values (see setUserDefineEntries). */ export function getUserProcessEnvDefineEntries(): Record { return userProcessEnvDefineEntries; diff --git a/packages/vite/hmr/server/deps-bundle.spec.ts b/packages/vite/hmr/server/deps-bundle.spec.ts index 4952205de5..5b64685b97 100644 --- a/packages/vite/hmr/server/deps-bundle.spec.ts +++ b/packages/vite/hmr/server/deps-bundle.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; +import { setUserDefineEntries } from '../../helpers/global-defines.js'; import { DEPS_BUNDLE_PATH, buildDepsBundleEntryCode, buildDepsCandidateSpecs, buildDepsShimCode, buildDepsVendorRuntimeModule, collectDepsModuleExportInfo, computeDepsBundleCacheKey, createDepsBundleService, depsRegistryKeyForFile, generateDepsBundle, isDepsPerModuleServingEnabled, resolveDepsEntriesFromRecording, resolveDepsEntriesFromVendorCollection } from './deps-bundle.js'; describe('isDepsPerModuleServingEnabled', () => { @@ -120,10 +121,41 @@ describe('collectDepsModuleExportInfo', () => { expect(collectDepsModuleExportInfo(abs, 'android', new Set()).names).toEqual(['AndroidCanvas', 'own']); }); - it('bails to null for bare export * (name set lives in another package) WITHOUT the opaqueCjs flag', () => { - // ESM bails must stay per-module: their bundled namespace has real named - // exports the server cannot enumerate, so a default-only shim would - // break `import { x }` consumers. Only CJS bails get opaqueCjs. + it('follows a bare export * into the package it resolves to (nativescript-vue → @vue/runtime-core shape)', () => { + // A bundled barrel with no shim is evaluated a second time per-module. + write('node_modules/star-peer/package.json', JSON.stringify({ name: 'star-peer', module: 'dist/peer.mjs' })); + write('node_modules/star-peer/dist/peer.mjs', `export const peerA = 1;\nexport * from './peer-more.mjs';\n`); + write('node_modules/star-peer/dist/peer-more.mjs', `export const peerB = 2;\n`); + const abs = write('node_modules/star-root/index.js', `export * from 'star-peer';\nexport const own = 1;\n`); + const info = collectDepsModuleExportInfo(abs, 'ios'); + expect(info.names).toEqual(['own', 'peerA', 'peerB']); + expect(info.hasDefault).toBe(false); + }); + + it('resolves bare export * subpaths through exports maps (exact and pattern entries) and platform suffixes', () => { + write('node_modules/sub-peer/package.json', JSON.stringify({ name: 'sub-peer', exports: { '.': './index.js', './exact': { import: './dist/exact.mjs' }, './lib/*': './dist/lib/*.mjs' } })); + write('node_modules/sub-peer/index.js', `export const root = 1;\n`); + write('node_modules/sub-peer/dist/exact.mjs', `export const exact = 1;\n`); + write('node_modules/sub-peer/dist/lib/deep.mjs', `export const deep = 1;\n`); + write('node_modules/plat-peer/package.json', JSON.stringify({ name: 'plat-peer', main: 'index.js' })); + write('node_modules/plat-peer/impl.ios.js', `export const impl = 'ios';\n`); + write('node_modules/plat-peer/impl.android.js', `export const androidImpl = 'android';\n`); + const abs = write('node_modules/sub-root/index.js', `export * from 'sub-peer/exact';\nexport * from 'sub-peer/lib/deep';\nexport * from 'plat-peer/impl';\n`); + expect(collectDepsModuleExportInfo(abs, 'ios').names).toEqual(['deep', 'exact', 'impl']); + expect(collectDepsModuleExportInfo(abs, 'android').names).toEqual(['androidImpl', 'deep', 'exact']); + }); + + it('resolves a bare export * from the nearest node_modules ancestor, like Node', () => { + write('node_modules/nested-peer/package.json', JSON.stringify({ name: 'nested-peer', main: 'index.js' })); + write('node_modules/nested-peer/index.js', `export const hoisted = 1;\n`); + write('node_modules/nested-root/node_modules/nested-peer/package.json', JSON.stringify({ name: 'nested-peer', main: 'index.js' })); + write('node_modules/nested-root/node_modules/nested-peer/index.js', `export const nested = 1;\n`); + const abs = write('node_modules/nested-root/index.js', `export * from 'nested-peer';\n`); + expect(collectDepsModuleExportInfo(abs, 'ios').names).toEqual(['nested']); + }); + + it('bails to null for a bare export * that resolves nowhere, WITHOUT the opaqueCjs flag', () => { + // Unresolvable ESM stars keep per-module serving; only CJS bails get opaqueCjs. const abs = write('bare-star.js', `export * from 'other-pkg';\nexport const own = 1;\n`); const info = collectDepsModuleExportInfo(abs, 'ios'); expect(info.names).toBeNull(); @@ -249,6 +281,9 @@ function createFixtureProject(): string { write('node_modules/pkg-j/package.json', JSON.stringify({ name: 'pkg-j', version: '1.0.0', main: 'dist/index.js' })); write('node_modules/pkg-j/index.ts', `export * from './src/not-shipped';\n`); write('node_modules/pkg-j/dist/index.js', `export const J = 'j-dist';\n`); + // Dep code reading app-level `__FOO__` defines (Vue feature-flag shape). + write('node_modules/pkg-flags/package.json', JSON.stringify({ name: 'pkg-flags', version: '1.0.0', module: 'index.js' })); + write('node_modules/pkg-flags/index.js', `export const optionsApi = typeof __VUE_OPTIONS_API__ === 'boolean' ? __VUE_OPTIONS_API__ : 'unset';\nexport const bad = typeof __BAD_DEFINE__ === 'undefined' ? 'unset' : __BAD_DEFINE__;\n`); return projectRoot; } @@ -360,6 +395,18 @@ describe('generateDepsBundle', () => { const state = await generateDepsBundle({ projectRoot, platform: 'ios', mode: 'development', flavor: 'typescript', recordedPaths }); expect(state!.code).toContain('platform polyfills'); }); + + it("substitutes the app's __FOO__ defines in bundled dep code, skipping values esbuild cannot define", async () => { + setUserDefineEntries({ __VUE_OPTIONS_API__: true, __BAD_DEFINE__: 'compute()' }); + try { + const state = await generateDepsBundle({ projectRoot, platform: 'ios', mode: 'development', flavor: 'vue', recordedPaths: ['/ns/m/node_modules/pkg-flags/index.js'] }); + expect(state).not.toBeNull(); + expect(state!.code).not.toContain('__VUE_OPTIONS_API__'); + expect(state!.code).toContain('__BAD_DEFINE__'); + } finally { + setUserDefineEntries(undefined); + } + }); }); // ============================================================================ @@ -530,6 +577,32 @@ describe('createDepsBundleService', () => { rmSync(seedRoot, { recursive: true, force: true }); }); + it('serves shims for a bundled root whose barrel re-exports a bare dependency (nativescript-vue shape)', async () => { + // Per-module serving here would re-run the root's top-level init(). + const root = mkdtempSync(path.join(realpathSync(tmpdir()), 'ns-deps-bare-star-')); + const write = (rel: string, contents: string) => { + const abs = path.join(root, rel); + mkdirSync(path.dirname(abs), { recursive: true }); + writeFileSync(abs, contents); + }; + write('package.json', JSON.stringify({ name: 'fixture-app', version: '1.0.0' })); + write('node_modules/ns-framework/package.json', JSON.stringify({ name: 'ns-framework', version: '1.0.0', main: 'dist/index.js' })); + write('node_modules/ns-framework/dist/index.js', `import { init } from './runtime.js';\ninit();\nexport * from 'ns-framework-core';\nexport { init };\n`); + write('node_modules/ns-framework/dist/runtime.js', `export function init() {}\n`); + write('node_modules/ns-framework-core/package.json', JSON.stringify({ name: 'ns-framework-core', version: '1.0.0', module: 'dist/core.mjs' })); + write('node_modules/ns-framework-core/dist/core.mjs', `export const ref = () => 1;\nexport const createApp = () => 2;\n`); + const service = createDepsBundleService({ projectRoot: root, platform: 'ios', mode: 'development', flavor: 'vue', getRecordedPaths: () => ['/ns/m/node_modules/ns-framework'] }); + await service.ensureBuilt(); + for (const spec of ['/node_modules/ns-framework', '/node_modules/ns-framework/dist/index.js']) { + const shim = service.getShimForSpec(spec); + expect(shim).toContain(`import "${DEPS_BUNDLE_PATH}";`); + expect(shim).toContain('export const init = '); + expect(shim).toContain('export const createApp = '); + expect(shim).toContain('export const ref = '); + } + rmSync(root, { recursive: true, force: true }); + }); + it('stops handing out new shims after disableServingForSession but keeps the payload servable', async () => { const service = makeService(); await service.ensureBuilt(); diff --git a/packages/vite/hmr/server/deps-bundle.ts b/packages/vite/hmr/server/deps-bundle.ts index dbc95ad622..43763cb795 100644 --- a/packages/vite/hmr/server/deps-bundle.ts +++ b/packages/vite/hmr/server/deps-bundle.ts @@ -58,7 +58,7 @@ import * as esbuild from 'esbuild'; import type { ViteDevServer } from 'vite'; import { resolvePlatform } from '../../helpers/cli-flags.js'; -import { getGlobalDefines } from '../../helpers/global-defines.js'; +import { getGlobalDefines, getUserDefineEntries } from '../../helpers/global-defines.js'; import { getProjectFlavor } from '../../helpers/flavor.js'; import { getMonorepoWorkspaceRoot } from '../../helpers/project.js'; import { createNativeClassEsbuildPlugin } from '../../helpers/nativeclass-esbuild-plugin.js'; @@ -132,6 +132,35 @@ export function buildDepsCandidateSpecs(spec: string, platform: string): string[ return [...(hasExt ? [spec] : []), ...exts.map((ext) => baseNoExt + ext), ...exts.map((ext) => baseNoExt + '/index' + ext)]; } +function collectExportsMapTargets(node: unknown, out: string[]): void { + if (!node) return; + if (typeof node === 'string') { + out.push(node); + return; + } + if (typeof node === 'object' && !Array.isArray(node)) { + // Prefer client/ESM conditions in the order esbuild would. + for (const cond of ['module', 'import', 'browser', 'default', 'require']) { + const next = (node as Record)[cond]; + if (next !== undefined) collectExportsMapTargets(next, out); + } + } +} + +function resolvePackageFileCandidates(pkgDir: string, candidates: readonly string[], platform: string): string | null { + const exts = platformResolveExtensions(platform); + for (const cand of candidates) { + const abs = path.resolve(pkgDir, cand); + if (!abs.startsWith(pkgDir + path.sep)) continue; + if (existsSync(abs) && statSync(abs).isFile()) return abs; + const base = abs.replace(SCRIPT_EXT_RE, ''); + for (const candidate of [...exts.map((ext) => base + ext), ...exts.map((ext) => base + '/index' + ext)]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + } + return null; +} + /** * Resolve a package ROOT spec (`/node_modules/`) to its entry file via * the package's own `exports['.']`/`module`/`main`, honoring platform @@ -154,34 +183,41 @@ function resolvePackageRootEntry(spec: string, roots: readonly string[], platfor continue; } const candidates: string[] = []; - const visitExports = (node: any) => { - if (!node) return; - if (typeof node === 'string') { - candidates.push(node); - return; - } - if (typeof node === 'object' && !Array.isArray(node)) { - // Prefer client/ESM conditions in the order esbuild would. - for (const cond of ['module', 'import', 'browser', 'default', 'require']) { - if (node[cond] !== undefined) visitExports(node[cond]); - } - } - }; - visitExports(pkg.exports?.['.'] ?? (typeof pkg.exports === 'string' ? pkg.exports : undefined)); + collectExportsMapTargets(pkg.exports?.['.'] ?? (typeof pkg.exports === 'string' ? pkg.exports : undefined), candidates); if (typeof pkg.module === 'string') candidates.push(pkg.module); if (typeof pkg.main === 'string') candidates.push(pkg.main); candidates.push('index'); - for (const cand of candidates) { - const abs = path.resolve(pkgDir, cand); - if (!abs.startsWith(pkgDir + path.sep)) continue; - if (existsSync(abs) && statSync(abs).isFile()) return abs; - const base = abs.replace(SCRIPT_EXT_RE, ''); - for (const ext of platformResolveExtensions(platform)) { - if (existsSync(base + ext)) return base + ext; + const resolved = resolvePackageFileCandidates(pkgDir, candidates, platform); + if (resolved) return resolved; + } + return null; +} + +function resolvePackageSubpathEntry(pkgDir: string, subpath: string, platform: string): string | null { + const candidates: string[] = []; + try { + const pkg = JSON.parse(readFileSync(path.join(pkgDir, 'package.json'), 'utf-8')); + const exportsMap = pkg.exports && typeof pkg.exports === 'object' && !Array.isArray(pkg.exports) ? (pkg.exports as Record) : null; + if (exportsMap) { + const key = `./${subpath}`; + collectExportsMapTargets(exportsMap[key], candidates); + for (const [pattern, target] of Object.entries(exportsMap)) { + const star = pattern.indexOf('*'); + if (star === -1) continue; + const prefix = pattern.slice(0, star); + const suffix = pattern.slice(star + 1); + if (key.length < prefix.length + suffix.length || !key.startsWith(prefix) || !key.endsWith(suffix)) continue; + const captured = key.slice(prefix.length, key.length - suffix.length); + const targets: string[] = []; + collectExportsMapTargets(target, targets); + for (const t of targets) candidates.push(t.split('*').join(captured)); } } + } catch { + // Unreadable manifest: fall through to the on-disk lookup. } - return null; + candidates.push(subpath); + return resolvePackageFileCandidates(pkgDir, candidates, platform); } /** `/node_modules/` or `/node_modules/@scope/` with no subpath. */ @@ -567,6 +603,19 @@ export function saveDepsBundleToDisk(projectRoot: string, platform: string, key: } } +// esbuild `define` takes JSON literals or identifier/member chains only. +const DEFINE_IDENTIFIER_CHAIN_RE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/; + +function isEsbuildDefineValue(expr: string): boolean { + if (DEFINE_IDENTIFIER_CHAIN_RE.test(expr)) return true; + try { + JSON.parse(expr); + return true; + } catch { + return false; + } +} + export async function generateDepsBundle(options: GenerateDepsBundleOptions): Promise { const { projectRoot, platform, mode, flavor, verbose } = options; const t0 = Date.now(); @@ -605,6 +654,10 @@ export async function generateDepsBundle(options: GenerateDepsBundleOptions): Pr // substitutes free references, so CJS-wrapped code (where `module` is a // bound variable) is untouched. out['module.hot'] = 'undefined'; + // App `__FOO__` defines reach dep code here exactly as in bundle.mjs. + for (const [key, expr] of getUserDefineEntries()) { + if (out[key] === undefined && isEsbuildDefineValue(expr)) out[key] = expr; + } return out; })(); @@ -770,6 +823,23 @@ function resolveLocalReExportTarget(spec: string, importerPath: string, platform return null; } +// Node-style resolution from the importer: nearest node_modules ancestor wins. +function resolveBareReExportTarget(spec: string, importerPath: string, platform: string): string | null { + const match = /^((?:@[^/]+\/)?[^/]+)(?:\/(.+))?$/.exec(spec); + if (!match) return null; + const [, pkgName, subpath] = match; + let dir = path.dirname(importerPath); + for (;;) { + const pkgDir = path.join(dir, 'node_modules', pkgName); + if (existsSync(path.join(pkgDir, 'package.json'))) { + return subpath ? resolvePackageSubpathEntry(pkgDir, subpath, platform) : resolvePackageRootEntry(`/node_modules/${pkgName}`, [dir], platform); + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + // Static CJS export-name discovery: `exports.NAME = ...` and // `Object.defineProperty(exports, "NAME", ...)` assignments (transpiled-to-CJS // packages like downsample). UMD factories (`module.exports = fn()`) expose no @@ -792,16 +862,18 @@ function collectCjsExportInfo(code: string): DepsModuleExportInfo { /** * Static export-name discovery for a node_modules file. ESM: direct exports - * plus recursion through RELATIVE `export * from` chains (the shape deep - * package barrels like rxjs's index take). CJS: `exports.NAME` assignment - * scanning (esbuild's interop exposes those names on the bundled namespace at - * runtime). Returns `names: null` for files whose export surface is not - * statically enumerable — with two DIFFERENT consequences downstream: - * CJS/UMD files additionally carry `opaqueCjs: true` and still get a - * default-only shim (their bundled namespace has nothing but `default`); - * ESM files with a bare `export * from 'pkg'` or an unresolvable relative - * `export *` target keep per-module serving, where the transform pipeline - * resolves the star through the import map. + * plus recursion through `export * from` chains — relative targets (the + * shape deep package barrels like rxjs's index take) and bare package targets + * resolved from the importer like Node would (nativescript-vue re-exporting + * `@vue/runtime-core`). CJS: `exports.NAME` assignment scanning (esbuild's + * interop exposes those names on the bundled namespace at runtime). Returns + * `names: null` for files whose export surface is not statically enumerable — + * with two DIFFERENT consequences downstream: CJS/UMD files additionally carry + * `opaqueCjs: true` and still get a default-only shim (their bundled + * namespace has nothing but `default`); ESM files with an `export *` target + * that resolves nowhere on disk keep per-module serving, where the transform + * pipeline resolves the star through the import map. A BUNDLED file served + * per-module evaluates a second time, so any resolvable star must be followed. */ export function collectDepsModuleExportInfo(absPath: string, platform: string, seen: Set = new Set()): DepsModuleExportInfo { if (seen.has(absPath)) return { names: [], hasDefault: false }; @@ -828,8 +900,7 @@ export function collectDepsModuleExportInfo(absPath: string, platform: string, s const starRe = /^[ \t]*export\s+\*\s+from\s+["']([^"']+)["']/gm; while ((match = starRe.exec(code)) !== null) { const spec = match[1]; - if (!spec.startsWith('.')) return { names: null, hasDefault: false }; - const target = resolveLocalReExportTarget(spec, absPath, platform); + const target = spec.startsWith('.') ? resolveLocalReExportTarget(spec, absPath, platform) : resolveBareReExportTarget(spec, absPath, platform); if (!target) return { names: null, hasDefault: false }; const child = collectDepsModuleExportInfo(target, platform, seen); if (child.names === null) return { names: null, hasDefault: false };