Skip to content
Merged
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
75 changes: 51 additions & 24 deletions packages/vite/hmr/shared/vendor/manifest-collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,43 +764,66 @@ function compiledJsExtensionsForPlatform(platform: string | undefined): string[]
*
* Skip:
* - `link:` and `workspace:` refs (always app-side source).
* - `file:` refs to a directory whose installed package only ships
* TypeScript/JSX source (no compiled `.js`/`.mjs`/`.cjs` entry).
* - Any installed package — whatever its spec — that only ships
* TypeScript/JSX source (no compiled `.js`/`.mjs`/`.cjs` entry). This
* covers semver/git/url deps published as raw source and semver specs
* whose install linked a source checkout (e.g. a pnpm `readPackage`
* hook), not just `file:` directory refs.
*
* Keep (return false):
* - Regular semver / git / url specs (normal third-party libraries).
* - Local `.tgz` file refs (pre-packaged libraries extracted at install).
* - `file:` directory refs that resolve to a package with a compiled JS
* entry — a common NativeScript monorepo convention where the app's
* - Packages with a compiled JS entry — including `file:` directory refs
* (a common NativeScript monorepo convention where the app's
* `package.json` redirects to `../../node_modules/<name>` to avoid
* duplicate installs while letting the NativeScript CLI discover
* plugins from the app directory.
* plugins from the app directory).
* - Packages whose entry can't be classified, except `file:` directory
* refs.
*/
function isUnvendorableLocalSource(name: string, spec: unknown, projectRequire: ReturnType<typeof createRequire>, platform?: string): boolean {
if (typeof spec !== 'string') return false;
if (spec.startsWith('link:') || spec.startsWith('workspace:')) return true;
if (!spec.startsWith('file:')) return false;
// Tarballs are already pre-packaged libraries — install extracts them
// into a normal node_modules entry.
if (/\.t(?:ar\.)?gz(?:[?#].*)?$/.test(spec)) return false;
// Directory file: refs need a deeper check: peek at the installed
// package.json and ask "does this ship compiled JS?". If yes, vendor
// it. If no (TS source only), defer to the regular Vite pipeline.
return !packageHasCompiledJsEntry(name, projectRequire, platform);
const entry = classifyPackageEntry(name, projectRequire, platform);
if (entry === 'compiled') return false;
if (entry === 'source') return true;
// Unclassifiable entries fall back to the spec: `file:` directory refs
// skip, everything else (semver/git/url specs, `file:` tarballs) vendors.
if (spec.startsWith('file:') && !/\.t(?:ar\.)?gz(?:[?#].*)?$/.test(spec)) return true;
return false;
}

function packageHasCompiledJsEntry(name: string, projectRequire: ReturnType<typeof createRequire>, platform?: string): boolean {
let pkgJsonPath: string;
/**
* Classify an installed package's entry point:
* - `'compiled'` — a `.js`/`.mjs`/`.cjs` entry file (or its platform
* variant like `index.ios.js`) exists on disk.
* - `'source'` — a declared entry file exists on disk, but none is
* compiled JS.
* - `'unknown'` — the installed package.json can't be found or parsed, or
* no declared entry exists on disk. A package without a root entry
* (subpath-only or wildcard `exports`) is not evidence of source, and
* its subpaths may still be vendored as monorepo root deps.
*/
function classifyPackageEntry(name: string, projectRequire: ReturnType<typeof createRequire>, platform?: string): 'compiled' | 'source' | 'unknown' {
let pkgJsonPath: string | null = null;
try {
pkgJsonPath = projectRequire.resolve(`${name}/package.json`);
} catch {
return false;
// `exports` maps that omit `./package.json` refuse the subpath
// resolve — fall back to a direct path probe over the require
// resolution dirs.
for (const dir of projectRequire.resolve.paths(name) ?? []) {
const candidate = path.join(dir, name, 'package.json');
if (fs.existsSync(candidate)) {
pkgJsonPath = candidate;
break;
}
}
}
if (!pkgJsonPath) return 'unknown';
let pkg: any;
try {
pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
} catch {
return false;
return 'unknown';
}
const pkgDir = path.dirname(pkgJsonPath);
const candidates: string[] = [];
Expand Down Expand Up @@ -832,6 +855,7 @@ function packageHasCompiledJsEntry(name: string, projectRequire: ReturnType<type
candidates.push('index');
}
const extensionsToTry = compiledJsExtensionsForPlatform(platform);
let sourceEntryFound = false;
for (const cand of candidates) {
const abs = path.resolve(pkgDir, cand);
const ext = path.extname(cand);
Expand All @@ -841,27 +865,30 @@ function packageHasCompiledJsEntry(name: string, projectRequire: ReturnType<type
// but ships only platform variants like `index.ios.js`), try
// the platform-specific variants which esbuild's
// `resolveExtensions` will pick up at bundle time.
if (fs.existsSync(abs)) return true;
if (fs.existsSync(abs)) return 'compiled';
const baseAbs = abs.slice(0, -ext.length);
for (const e of extensionsToTry) {
if (fs.existsSync(baseAbs + e)) return true;
if (fs.existsSync(baseAbs + e)) return 'compiled';
}
continue;
}
if (!ext) {
// Extensionless main — try plain JS variants AND the
// platform-specific variants NativeScript plugins commonly use
// (`index.ios.js`, `index.android.js`, `index.visionos.js`).
// (`index.ios.js`, `index.android.js`, `index.visionos.js`), as
// both a file (`<abs>.js`) and a directory (`<abs>/index.js`).
for (const e of extensionsToTry) {
if (fs.existsSync(abs + e)) return true;
if (fs.existsSync(abs + e) || fs.existsSync(path.join(abs, `index${e}`))) return 'compiled';
}
if (['.ts', '.tsx', '.mts', '.cts'].some((e) => fs.existsSync(abs + e) || fs.existsSync(path.join(abs, `index${e}`)))) sourceEntryFound = true;
continue;
}
// A non-JS extension (typically `.ts`/`.tsx`) means the package
// only ships TS source — esbuild's vendor pipeline can't resolve
// any tsconfig-aliased imports inside it.
if (fs.existsSync(abs)) sourceEntryFound = true;
}
return false;
return sourceEntryFound ? 'source' : 'unknown';
}

function canResolveDependencyPackageJson(specifier: string, projectRequire: ReturnType<typeof createRequire>): boolean {
Expand Down
135 changes: 134 additions & 1 deletion packages/vite/hmr/shared/vendor/manifest.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { afterEach, describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -133,10 +133,13 @@ describe('collectVendorModules', () => {
// `addCandidate`, that pull is suppressed.
mkdirSync(join(root, 'node_modules', '@nativescript-community', 'solid-js'), { recursive: true });
writeFileSync(join(root, 'node_modules', '@nativescript-community', 'solid-js', 'package.json'), JSON.stringify({ name: '@nativescript-community/solid-js', version: '0.1.2', peerDependencies: { 'solid-js': '^1.8.0' } }, null, 2));
writeFileSync(join(root, 'node_modules', '@nativescript-community', 'solid-js', 'index.js'), 'module.exports = {};\n');
mkdirSync(join(root, 'node_modules', 'solid-js'), { recursive: true });
writeFileSync(join(root, 'node_modules', 'solid-js', 'package.json'), JSON.stringify({ name: 'solid-js', version: '1.9.0' }, null, 2));
writeFileSync(join(root, 'node_modules', 'solid-js', 'index.js'), 'module.exports = {};\n');
mkdirSync(join(root, 'node_modules', 'solid-navigation'), { recursive: true });
writeFileSync(join(root, 'node_modules', 'solid-navigation', 'package.json'), JSON.stringify({ name: 'solid-navigation', version: '1.0.0' }, null, 2));
writeFileSync(join(root, 'node_modules', 'solid-navigation', 'index.js'), 'module.exports = {};\n');

const collected = collectVendorModules(root, 'ios', 'solid');

Expand All @@ -163,6 +166,7 @@ describe('collectVendorModules', () => {
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '0.0.0', dependencies: { 'solid-js': '*' } }, null, 2));
mkdirSync(join(root, 'node_modules', 'solid-js'), { recursive: true });
writeFileSync(join(root, 'node_modules', 'solid-js', 'package.json'), JSON.stringify({ name: 'solid-js', version: '1.9.0' }, null, 2));
writeFileSync(join(root, 'node_modules', 'solid-js', 'index.js'), 'module.exports = {};\n');

const collected = collectVendorModules(root, 'ios', 'vue');

Expand All @@ -180,8 +184,10 @@ describe('collectVendorModules', () => {
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '0.0.0', dependencies: { '@acme/octane-ns': '*', octane: '*' } }, null, 2));
mkdirSync(join(root, 'node_modules', '@acme', 'octane-ns'), { recursive: true });
writeFileSync(join(root, 'node_modules', '@acme', 'octane-ns', 'package.json'), JSON.stringify({ name: '@acme/octane-ns', version: '1.0.0', peerDependencies: { octane: '*' } }, null, 2));
writeFileSync(join(root, 'node_modules', '@acme', 'octane-ns', 'index.js'), 'module.exports = {};\n');
mkdirSync(join(root, 'node_modules', 'octane'), { recursive: true });
writeFileSync(join(root, 'node_modules', 'octane', 'package.json'), JSON.stringify({ name: 'octane', version: '1.0.0' }, null, 2));
writeFileSync(join(root, 'node_modules', 'octane', 'index.js'), 'module.exports = {};\n');
registerFrameworkFlavor({ flavor: 'acme', server: { ...typescriptServerStrategy, flavor: 'acme' }, client: '@acme/octane-ns/client', vendor: { exclude: ['octane'] } });

expect(collectVendorModules(root, 'ios', 'acme').entries).toEqual(['@acme/octane-ns']);
Expand Down Expand Up @@ -212,3 +218,130 @@ describe('collectVendorModules', () => {
expect(code).toContain('export default vendorManifest;');
});
});

describe('collectVendorModules — source-only dependencies', () => {
const tempRoots: string[] = [];

afterEach(() => {
while (tempRoots.length) {
const root = tempRoots.pop();
if (root) {
rmSync(root, { recursive: true, force: true });
}
}
});

const makeApp = (deps: Record<string, string>): string => {
const root = mkdtempSync(join(tmpdir(), 'ns-vendor-source-only-'));
tempRoots.push(root);
mkdirSync(join(root, 'node_modules'), { recursive: true });
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '0.0.0', dependencies: deps }));
return root;
};

const installPackage = (root: string, name: string, pkg: Record<string, unknown>, files: Record<string, string> = {}) => {
const dir = join(root, 'node_modules', ...name.split('/'));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: '1.0.0', ...pkg }));
for (const [rel, content] of Object.entries(files)) {
mkdirSync(join(dir, rel, '..'), { recursive: true });
writeFileSync(join(dir, rel), content);
}
};

it('excludes a semver dep whose package exports only TS source', () => {
const root = makeApp({ 'src-lib': '1.0.0' });
installPackage(root, 'src-lib', { exports: { '.': { types: './src/index.d.ts', default: './src/index.ts' }, './package.json': './package.json' } }, { 'src/index.ts': 'export const x = 1;\n', 'src/index.d.ts': 'export declare const x: number;\n' });

expect(collectVendorModules(root, 'ios', 'typescript').entries).not.toContain('src-lib');
});

it('excludes a semver dep hook-linked to an external source checkout', () => {
const root = makeApp({ 'hook-lib': '1.0.0' });
const checkout = mkdtempSync(join(tmpdir(), 'ns-vendor-source-checkout-'));
tempRoots.push(checkout);
const depDir = join(checkout, 'hook-lib');
mkdirSync(join(depDir, 'src'), { recursive: true });
writeFileSync(join(depDir, 'package.json'), JSON.stringify({ name: 'hook-lib', version: '1.0.0', main: 'src/index.ts' }));
writeFileSync(join(depDir, 'src/index.ts'), 'export const y = 1;\n');
symlinkSync(depDir, join(root, 'node_modules', 'hook-lib'), 'dir');

expect(collectVendorModules(root, 'ios', 'typescript').entries).not.toContain('hook-lib');
});

it('vendors a semver dep with a compiled entry when exports omit ./package.json', () => {
const root = makeApp({ 'compiled-lib': '1.0.0' });
installPackage(root, 'compiled-lib', { exports: { '.': './dist/index.js' } }, { 'dist/index.js': 'module.exports = {};\n' });

expect(collectVendorModules(root, 'ios', 'typescript').entries).toContain('compiled-lib');
});

it('vendors a semver dep whose extensionless main names a directory with index.js', () => {
const root = makeApp({ 'dir-main-lib': '1.0.0' });
installPackage(root, 'dir-main-lib', { main: 'lib' }, { 'lib/index.js': 'module.exports = {};\n' });

expect(collectVendorModules(root, 'ios', 'typescript').entries).toContain('dir-main-lib');
});

it('vendors a semver NativeScript plugin shipping only platform-suffixed entries', () => {
const root = makeApp({ 'ns-plugin': '1.0.0' });
installPackage(root, 'ns-plugin', { main: 'index' }, { 'index.ios.js': 'module.exports = {};\n', 'index.android.js': 'module.exports = {};\n' });

expect(collectVendorModules(root, 'ios', 'typescript').entries).toContain('ns-plugin');
});

it('keeps vendoring a file: tarball spec whose installed package has compiled JS', () => {
const root = makeApp({ 'tgz-lib': 'file:./pkg.tgz' });
installPackage(root, 'tgz-lib', { main: 'index.js' }, { 'index.js': 'module.exports = {};\n' });

expect(collectVendorModules(root, 'ios', 'typescript').entries).toContain('tgz-lib');
});

it('vendors a compiled file: dir package but not a source-only one', () => {
const root = makeApp({ 'file-compiled': 'file:../file-compiled', 'file-source': 'file:../file-source' });
installPackage(root, 'file-compiled', { main: 'index.js' }, { 'index.js': 'module.exports = {};\n' });
installPackage(root, 'file-source', { main: 'src/index.ts' }, { 'src/index.ts': 'export const z = 1;\n' });

const entries = collectVendorModules(root, 'ios', 'typescript').entries;
expect(entries).toContain('file-compiled');
expect(entries).not.toContain('file-source');
});

it('keeps the pristine result for a declared-but-not-installed dep', () => {
const root = makeApp({ 'ghost-lib': '1.0.0' });

expect(collectVendorModules(root, 'ios', 'typescript').entries).toContain('ghost-lib');
});

it('vendors a semver plugin whose declared main is missing on disk', () => {
const root = makeApp({ 'stale-main-plugin': '1.0.0' });
installPackage(root, 'stale-main-plugin', { main: 'plugin' }, { 'index.ios.js': 'module.exports = {};\n', 'index.android.js': 'module.exports = {};\n' });

expect(collectVendorModules(root, 'ios', 'typescript').entries).toContain('stale-main-plugin');
});

it('excludes a semver dependency whose extensionless main resolves only to TS source', () => {
const root = makeApp({ 'ts-main-lib': '1.0.0', 'ts-dir-lib': '1.0.0' });
installPackage(root, 'ts-main-lib', { main: 'src/index' }, { 'src/index.ts': 'export const x = 1;\n' });
installPackage(root, 'ts-dir-lib', { main: 'src' }, { 'src/index.tsx': 'export const y = 1;\n' });

const entries = collectVendorModules(root, 'ios', 'typescript').entries;
expect(entries).not.toContain('ts-main-lib');
expect(entries).not.toContain('ts-dir-lib');
});

it('vendors imported subpaths of monorepo root deps that have no root entry', () => {
const workspace = makeApp({ 'subpath-lib': '1.0.0', 'wildcard-lib': '1.0.0' });
writeFileSync(join(workspace, 'nx.json'), '{}');
installPackage(workspace, 'subpath-lib', {}, { 'a.js': 'export default 1;\n' });
installPackage(workspace, 'wildcard-lib', { exports: { './*': './dist/*.js' } }, { 'dist/w.js': 'export default 2;\n' });
const app = join(workspace, 'apps', 'app');
mkdirSync(join(app, 'src'), { recursive: true });
writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app', version: '0.0.0', dependencies: {} }));
writeFileSync(join(app, 'src', 'app.ts'), "import a from 'subpath-lib/a.js';\nimport w from 'wildcard-lib/w';\nconsole.log(a, w);\n");

const entries = collectVendorModules(app, 'ios', 'typescript').entries;
expect(entries).toContain('subpath-lib/a.js');
expect(entries).toContain('wildcard-lib/w');
});
});
Loading