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
42 changes: 28 additions & 14 deletions packages/vite/configuration/solid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { mergeConfig, type UserConfig } from 'vite';
import { baseConfig } from './base.js';
import { getTypeCheckPlugins, type TypeCheckControlOptions } from '../helpers/typescript-check.js';
import { findSolidPackagesShippingJsx } from '../hmr/frameworks/solid/build/solid-jsx-deps.js';
import { resolveSolidDevEntry, SOLID_RUNTIME_PACKAGES } from '../helpers/solid-runtime.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand All @@ -22,22 +23,35 @@ const jsxRuntimeShimPath = resolve(dirname(__dirname), 'shims', 'solid-jsx-runti
// Build the Solid plugin stack for a given build kind. `prod` is derived from
// Vite's `mode` inside the factory (NOT a non-standard `process.env.production`),
// so a production build resolves Solid's prod bundles and disables dev/hot.
const exactAlias = (spec: string, replacement: string) => ({ find: new RegExp(`^${spec.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}$`), replacement });

// Matches are anchored: other subpaths (e.g. Solid 2's `solid-js/internal`)
// must fall through to the package's exports map.
const buildSolidAliasEntries = (prod: boolean) => {
const entries = [
// Solid 1.x subpath layout.
exactAlias('solid-js/web', resolve(solidPath, `web/dist/${prod ? 'web' : 'dev'}.js`)),
exactAlias('solid-js/universal', resolve(solidPath, `universal/dist/${prod ? 'universal' : 'dev'}.js`)),
exactAlias('solid-js/store', resolve(solidPath, `store/dist/${prod ? 'store' : 'dev'}.js`)),
exactAlias('solid-js/jsx-runtime', jsxRuntimeShimPath),
exactAlias('solid-js/jsx-dev-runtime', jsxRuntimeShimPath),
exactAlias('solid-js', prod ? resolve(solidPath, 'dist/solid.js') : (resolveSolidDevEntry('solid-js', projectRoot)?.absPath ?? resolve(solidPath, 'dist/dev.js'))),
];
if (!prod) {
// base.ts's resolve.conditions omit `development`, so without these the
// remaining runtime packages resolve to prod builds next to solid-js dev.
for (const pkg of SOLID_RUNTIME_PACKAGES) {
if (pkg === 'solid-js') continue;
const devEntry = resolveSolidDevEntry(pkg, projectRoot);
if (devEntry) entries.push(exactAlias(pkg, devEntry.absPath));
}
}
return entries;
};

const buildSolidPlugins = (prod: boolean) => [
{
...alias({
entries: {
// Fix the web alias - it should NOT point to web/dist but to the main dist
'solid-js/web': resolve(solidPath, `web/dist/${prod ? 'web' : 'dev'}.js`),
// Alias solid-js modules to proper locations
'solid-js/universal': resolve(solidPath, `universal/dist/${prod ? 'universal' : 'dev'}.js`),
'solid-js/store': resolve(solidPath, `store/dist/${prod ? 'store' : 'dev'}.js`),
// Automatic JSX transform runtime — must come before the catch-all
// 'solid-js' entry to avoid prefix-matching to solid-js/dist/dev.js
'solid-js/jsx-runtime': jsxRuntimeShimPath,
'solid-js/jsx-dev-runtime': jsxRuntimeShimPath,
'solid-js': resolve(solidPath, `dist/${prod ? 'solid' : 'dev'}.js`),
},
}),
...alias({ entries: buildSolidAliasEntries(prod) }),
enforce: 'pre',
},
// Enable SolidJS support with NativeScript configuration
Expand Down
121 changes: 121 additions & 0 deletions packages/vite/helpers/solid-runtime.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { clearSolidDevEntryCache, getSolidDevEntryNsmPath, getSolidJsDevEntryRelPath, isSolidRuntimePackage, resolveSolidDevEntry } from './solid-runtime.js';

// Export maps copied from the published packages.
const SOLID_1_9_9_EXPORTS = {
'.': {
browser: { types: './types/index.d.ts', import: './dist/solid.js', require: './dist/solid.cjs', development: { types: './types/index.d.ts', import: './dist/dev.js', require: './dist/dev.cjs' } },
development: { types: './types/index.d.ts', import: './dist/dev.js', require: './dist/dev.cjs' },
import: './dist/solid.js',
},
};
const SOLID_2_RC_0_EXPORTS = {
'.': {
browser: { import: { default: './dist/solid.js' }, development: { import: { default: './dist/dev.js' } } },
development: { import: { default: './dist/dev.js' } },
import: { default: './dist/solid.js' },
},
};
const SOLID_2_RC_19_EXPORTS = {
'.': {
browser: { types: './types/index.d.ts', default: './dist/solid.js', development: { types: './types/index.d.ts', default: './dist/solid.dev.js' } },
development: { types: './types/index.d.ts', default: './dist/solid.dev.js' },
default: './dist/solid.js',
},
'./internal': { default: './dist/internal.js' },
};
const SIGNALS_RC_0_EXPORTS = { '.': { import: { test: './dist/dev.js', types: './dist/types/index.d.ts', default: './dist/prod/index.js', development: './dist/dev.js' } } };
const SIGNALS_RC_21_EXPORTS = { '.': { types: './dist/types/index.d.ts', test: './dist/dev.js', development: './dist/dev.js', observe: './dist/observe/index.js', default: './dist/prod/index.js' } };

let root: string;

function installPackage(name: string, exportsMap: unknown, files: string[], base = root): void {
const dir = path.join(base, 'node_modules', ...name.split('/'));
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, exports: exportsMap }));
for (const file of files) {
mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
writeFileSync(path.join(dir, file), '');
}
}

beforeEach(() => {
root = mkdtempSync(path.join(tmpdir(), 'ns-solid-runtime-'));
clearSolidDevEntryCache();
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

describe('resolveSolidDevEntry', () => {
it.each([
['1.9.9', SOLID_1_9_9_EXPORTS, ['dist/dev.js', 'dist/solid.js'], 'dist/dev.js'],
['2.0.0-rc.0', SOLID_2_RC_0_EXPORTS, ['dist/dev.js', 'dist/solid.js'], 'dist/dev.js'],
['2.0.0-rc.19', SOLID_2_RC_19_EXPORTS, ['dist/solid.dev.js', 'dist/solid.js'], 'dist/solid.dev.js'],
])('resolves solid-js@%s to its dev build', (_version, exportsMap, files, expected) => {
installPackage('solid-js', exportsMap, files);
expect(resolveSolidDevEntry('solid-js', root)).toEqual({ absPath: path.join(root, 'node_modules/solid-js', expected), relPath: expected });
});

it.each([
['2.0.0-rc.0', SIGNALS_RC_0_EXPORTS],
['2.0.0-rc.21', SIGNALS_RC_21_EXPORTS],
])('resolves @solidjs/signals@%s to dist/dev.js regardless of condition order', (_version, exportsMap) => {
installPackage('@solidjs/signals', exportsMap, ['dist/dev.js', 'dist/prod/index.js']);
expect(resolveSolidDevEntry('@solidjs/signals', root)?.relPath).toBe('dist/dev.js');
});

it('finds packages hoisted above the starting directory', () => {
installPackage('@solidjs/universal', { '.': { development: { default: './dist/universal.dev.js' }, default: './dist/universal.js' } }, ['dist/universal.dev.js']);
const nested = path.join(root, 'node_modules', 'solid-js', 'dist');
mkdirSync(nested, { recursive: true });
expect(resolveSolidDevEntry('@solidjs/universal', nested)?.relPath).toBe('dist/universal.dev.js');
});

it('returns null when the exports map names no dev build', () => {
installPackage('@solidjs/web', { '.': { default: './dist/web.js' } }, ['dist/web.js']);
expect(resolveSolidDevEntry('@solidjs/web', root)).toBeNull();
});

it('returns null when the mapped dev file is missing on disk', () => {
installPackage('@solidjs/signals', SIGNALS_RC_21_EXPORTS, ['dist/prod/index.js']);
expect(resolveSolidDevEntry('@solidjs/signals', root)).toBeNull();
});

it('returns null for uninstalled and non-Solid packages', () => {
expect(resolveSolidDevEntry('@solidjs/signals', root)).toBeNull();
installPackage('vue', { '.': { development: './dist/vue.dev.js' } }, ['dist/vue.dev.js']);
expect(resolveSolidDevEntry('vue', root)).toBeNull();
});
});

describe('getSolidDevEntryNsmPath', () => {
it('falls back to the legacy solid-js layout when solid-js is not found', () => {
expect(getSolidJsDevEntryRelPath(root)).toBe('dist/dev.js');
expect(getSolidDevEntryNsmPath('solid-js', root)).toBe('/ns/m/node_modules/solid-js/dist/dev.js');
});

it('builds /ns/m URLs from each package dev build', () => {
installPackage('solid-js', SOLID_2_RC_19_EXPORTS, ['dist/solid.dev.js']);
installPackage('@solidjs/signals', SIGNALS_RC_21_EXPORTS, ['dist/dev.js']);
expect(getSolidDevEntryNsmPath('solid-js', root)).toBe('/ns/m/node_modules/solid-js/dist/solid.dev.js');
expect(getSolidDevEntryNsmPath('@solidjs/signals', root)).toBe('/ns/m/node_modules/@solidjs/signals/dist/dev.js');
});

it('returns null for a non-solid-js package without a dev build', () => {
expect(getSolidDevEntryNsmPath('@solidjs/web', root)).toBeNull();
});
});

describe('isSolidRuntimePackage', () => {
it('matches only exact runtime package roots', () => {
expect(isSolidRuntimePackage('@solidjs/signals')).toBe(true);
expect(isSolidRuntimePackage('solid-js/internal')).toBe(false);
expect(isSolidRuntimePackage('solid-navigation')).toBe(false);
});
});
113 changes: 113 additions & 0 deletions packages/vite/helpers/solid-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';

/**
* Solid runtime packages whose root entry ships a separate dev build. In dev
* they must resolve to their dev builds together: Solid 2's `solid-js` dev
* build reads `DEV` from `@solidjs/signals` at module load, and the signals
* prod build exports it as `undefined`.
*
* Deliberately narrow — enabling the `development` condition globally breaks
* packages whose dev entries are environment-ambiguous stubs (see the
* conditions note in hmr/shared/vendor/manifest.ts).
*/
export const SOLID_RUNTIME_PACKAGES: readonly string[] = ['solid-js', '@solidjs/signals', '@solidjs/universal', '@solidjs/web'];

/** Pre-exports-map Solid layout; used when `exports` names no dev build. */
const LEGACY_SOLID_JS_DEV_ENTRY = 'dist/dev.js';

const CLIENT_CONDITIONS = ['browser', 'import', 'default'] as const;

export interface SolidDevEntry {
/** Absolute path of the dev build file. */
absPath: string;
/** Path relative to the package dir, e.g. `dist/solid.dev.js`. */
relPath: string;
}

const devEntryCache = new Map<string, SolidDevEntry | null>();

export function isSolidRuntimePackage(spec: string): boolean {
return SOLID_RUNTIME_PACKAGES.includes(spec);
}

function findPackageDir(pkgName: string, fromDir: string): string | null {
let dir = path.resolve(fromDir);
while (true) {
const candidate = path.join(dir, 'node_modules', ...pkgName.split('/'));
if (existsSync(path.join(candidate, 'package.json'))) return candidate;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}

// Only accepts targets reached through a `development` key, so a package whose
// map has no dev build yields null instead of silently returning its prod file.
function pickDevTarget(node: unknown, underDevelopment: boolean): string | null {
if (typeof node === 'string') return underDevelopment ? node : null;
if (!node || typeof node !== 'object' || Array.isArray(node)) return null;
const map = node as Record<string, unknown>;
for (const cond of ['development', ...CLIENT_CONDITIONS]) {
if (!(cond in map)) continue;
const hit = pickDevTarget(map[cond], underDevelopment || cond === 'development');
if (hit) return hit;
}
return null;
}

function rootExportsNode(exportsField: unknown): unknown {
if (!exportsField || typeof exportsField !== 'object' || Array.isArray(exportsField)) return exportsField;
const map = exportsField as Record<string, unknown>;
const isSubpathMap = Object.keys(map).some((key) => key.startsWith('.'));
return isSubpathMap ? map['.'] : map;
}

/**
* Resolves the dev build of a Solid runtime package's root entry from the
* package's own `exports` map (searching `node_modules` upward from
* `fromDir`), so it tracks Solid's file layout across 1.x and 2.x releases.
* Returns null when the package is not installed or ships no dev build.
*/
export function resolveSolidDevEntry(pkgName: string, fromDir: string): SolidDevEntry | null {
if (!isSolidRuntimePackage(pkgName)) return null;
const pkgDir = findPackageDir(pkgName, fromDir);
if (!pkgDir) return null;
const cached = devEntryCache.get(pkgDir);
if (cached !== undefined) return cached;

let result: SolidDevEntry | null = null;
try {
const pkgJson = JSON.parse(readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
const candidates: string[] = [];
const fromExports = pickDevTarget(rootExportsNode(pkgJson.exports), false);
if (fromExports) candidates.push(fromExports);
if (pkgName === 'solid-js') candidates.push(LEGACY_SOLID_JS_DEV_ENTRY);
for (const candidate of candidates) {
const absPath = path.resolve(pkgDir, candidate);
if (!absPath.startsWith(pkgDir + path.sep) || !existsSync(absPath)) continue;
result = { absPath, relPath: path.relative(pkgDir, absPath).split(path.sep).join('/') };
break;
}
} catch {
result = null;
}
devEntryCache.set(pkgDir, result);
return result;
}

/** `solid-js` dev entry relative to its package dir; falls back to the legacy layout. */
export function getSolidJsDevEntryRelPath(fromDir: string): string {
return resolveSolidDevEntry('solid-js', fromDir)?.relPath ?? LEGACY_SOLID_JS_DEV_ENTRY;
}

/** `/ns/m/node_modules/<pkg>/<dev entry>` for a Solid runtime package, or null. */
export function getSolidDevEntryNsmPath(pkgName: string, fromDir: string): string | null {
if (pkgName === 'solid-js') return `/ns/m/node_modules/solid-js/${getSolidJsDevEntryRelPath(fromDir)}`;
const entry = resolveSolidDevEntry(pkgName, fromDir);
return entry ? `/ns/m/node_modules/${pkgName}/${entry.relPath}` : null;
}

export function clearSolidDevEntryCache(): void {
devEntryCache.clear();
}
9 changes: 8 additions & 1 deletion packages/vite/hmr/frameworks/solid/server/strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ vi.mock('../../../server/websocket-hot-update.js', () => ({
runHotUpdatePrologue: vi.fn(),
}));

// A root with no solid-js installed, so importMapEntries takes the legacy-layout
// fallback regardless of what the workspace has installed.
vi.mock('../../../../helpers/project.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../helpers/project.js')>()),
getProjectRootPath: vi.fn(() => '/nonexistent-ns-solid-project'),
}));

const SOLID_REFRESH_ID = '/node_modules/@solid-refresh/dist/solid-refresh.mjs';

// A representative slice of the vendored `@solid-refresh` runtime carrying each
Expand Down Expand Up @@ -69,7 +76,7 @@ describe('solidServerStrategy.transformNodeModule (@solid-refresh patch)', () =>
});

describe('solidServerStrategy.importMapEntries', () => {
it('pins solid-js to the canonical HTTP dev.js URL so vendor + HTTP imports dedupe', () => {
it('pins solid-js to the canonical HTTP dev-build URL so vendor + HTTP imports dedupe', () => {
expect(solidServerStrategy.importMapEntries!('http://localhost:5173')).toEqual({
'solid-js': 'http://localhost:5173/ns/m/node_modules/solid-js/dist/dev.js',
});
Expand Down
6 changes: 4 additions & 2 deletions packages/vite/hmr/frameworks/solid/server/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { FrameworkProcessFileContext, FrameworkRegistryContext, FrameworkSe
import { ensureRoutesDefaultExport } from '../../../server/websocket-served-module-helpers.js';
import * as path from 'path';
import { getProjectAppVirtualPath } from '../../../../helpers/utils.js';
import { getProjectRootPath } from '../../../../helpers/project.js';
import { getSolidDevEntryNsmPath } from '../../../../helpers/solid-runtime.js';
import { purgeTransformCachesForHotUpdate } from '../../../server/transform-cache-invalidation.js';
import { runHotUpdatePrologue } from '../../../server/websocket-hot-update.js';

Expand Down Expand Up @@ -293,7 +295,7 @@ export const solidServerStrategy: FrameworkServerStrategy = {

return patchedCode;
},
// ── pin solid-js to the canonical HTTP dev.js URL ─────────────────────
// ── pin solid-js to the canonical HTTP dev-build URL ──────────────────
// The vendor bundle
// externalizes `solid-js` for this flavor, so all three import sites
// (vendor's externalized `import 'solid-js'`, user code's alias-rewritten
Expand All @@ -303,7 +305,7 @@ export const solidServerStrategy: FrameworkServerStrategy = {
// HTTP from discoverInstalledPackages().
importMapEntries(origin: string) {
return {
'solid-js': `${origin}/ns/m/node_modules/solid-js/dist/dev.js`,
'solid-js': `${origin}${getSolidDevEntryNsmPath('solid-js', getProjectRootPath())}`,
};
},
async processFile(ctx: FrameworkProcessFileContext) {
Expand Down
31 changes: 31 additions & 0 deletions packages/vite/hmr/server/deps-bundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,37 @@ describe('createDepsBundleService', () => {
rmSync(root, { recursive: true, force: true });
});

it('pins Solid runtime packages to their dev builds for bare imports, recorded roots, and root URLs', async () => {
const root = mkdtempSync(path.join(realpathSync(tmpdir()), 'ns-deps-solid-'));
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', dependencies: { 'solid-js': '2.0.0' } }));
write('node_modules/solid-js/package.json', JSON.stringify({ name: 'solid-js', version: '2.0.0', exports: { '.': { browser: { development: { default: './dist/solid.dev.js' }, default: './dist/solid.js' }, development: { default: './dist/solid.dev.js' }, default: './dist/solid.js' } } }));
write('node_modules/solid-js/dist/solid.dev.js', `import { DEV } from '@solidjs/signals';\nexport const guideUrl = DEV.guideUrl;\n`);
write('node_modules/solid-js/dist/solid.js', `import { DEV } from '@solidjs/signals';\nexport const guideUrl = DEV;\n`);
write('node_modules/@solidjs/signals/package.json', JSON.stringify({ name: '@solidjs/signals', version: '2.0.0', exports: { '.': { development: './dist/dev.js', default: './dist/prod/index.js' } } }));
write('node_modules/@solidjs/signals/dist/dev.js', `export const DEV = { guideUrl: () => '' };\n`);
write('node_modules/@solidjs/signals/dist/prod/index.js', `export const DEV = undefined;\n`);
const service = createDepsBundleService({
projectRoot: root,
platform: 'ios',
mode: 'development',
flavor: 'solid',
getRecordedPaths: () => ['/ns/m/node_modules/solid-js', '/ns/m/node_modules/@solidjs/signals'],
});
const state = await service.ensureBuilt();
expect(state!.keys).toContain('node_modules/solid-js/dist/solid.dev.js');
expect(state!.keys).toContain('node_modules/@solidjs/signals/dist/dev.js');
expect(state!.keys).not.toContain('node_modules/solid-js/dist/solid.js');
expect(state!.keys).not.toContain('node_modules/@solidjs/signals/dist/prod/index.js');
expect(service.getShimForSpec('/node_modules/@solidjs/signals')).toContain('"node_modules/@solidjs/signals/dist/dev.js"');
expect(service.getShimForSpec('/node_modules/solid-js')).toContain('"node_modules/solid-js/dist/solid.dev.js"');
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();
Expand Down
Loading
Loading