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
2 changes: 2 additions & 0 deletions packages/vite/docs/framework-flavors.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ Register at module scope, before `baseConfig` can run. `baseConfig` installs the

`getTypeCheckPlugins` takes the *kind* of type-check, not the flavor name: `'typescript'` for a `.ts`/`.tsx` project, `'vue'` for `vue-tsc`.

When a TypeScript project imports `.tsrx` files and has `@tsrx/typescript-plugin` installed, the build-time checker delegates to `tsrx-tsc` so TSRX files are transformed for type checking. It runs `tsrx-tsc` against a generated tsconfig under `node_modules/.ns-vite/` that extends the project's tsconfig, lists only the files selected for the current NativeScript platform, and declares `tsrx.platform` for that platform (`ios` for visionOS) so platform guards compile the way the build does. Without the plugin installed, `.tsrx` imports surface as unresolved modules in the regular check, with a hint to install it.

### Keeping a runtime package out of the dev vendor bundle

A dev session evaluates node_modules code from one bundle, seeded on the very first boot from every root in the app's `dependencies`. That seed is a guess at what the device will need, and a compiler can make it wrong: Octane rewrites `import { useState } from 'octane'` in the components it compiles to `@nativescript-community/octane`, which imports only `octane/universal/native`. Left in the seed, the `octane` root drags its whole DOM-side runtime into every dev boot for nothing.
Expand Down
138 changes: 138 additions & 0 deletions packages/vite/helpers/typescript-check.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { typescriptCheckPlugin } from './typescript-check.js';

const FAKE_TSRX_TSC = `
const fs = require('node:fs');
const configPath = process.argv[process.argv.indexOf('-p') + 1];
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
fs.writeFileSync(process.env.NS_TSRX_TEST_OUT, JSON.stringify({ argv: process.argv.slice(2), configPath, config }));
process.exit(Number(process.env.NS_TSRX_TEST_EXIT || 0));
`;

interface RecordedRun {
argv: string[];
configPath: string;
config: { extends: string; files: string[]; tsrx?: { platform: string }; compilerOptions: { moduleSuffixes: string[] } };
}

function createProject(): string {
const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ns-vite-tsrx-check-'));
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'tsrx-check-app', private: true }));
fs.writeFileSync(
path.join(root, 'tsconfig.json'),
JSON.stringify({
compilerOptions: { target: 'es2020', module: 'esnext', moduleResolution: 'bundler', strict: true, noEmit: true, noEmitOnError: true, skipLibCheck: true, lib: ['es2020'], types: [] },
include: ['src/**/*.ts'],
}),
);
fs.mkdirSync(path.join(root, 'src'));
fs.writeFileSync(path.join(root, 'src', 'main.ts'), "import { Greeting } from './Greeting.tsrx';\nexport const el = Greeting();\n");
fs.writeFileSync(path.join(root, 'src', 'Greeting.tsrx'), 'export function Greeting() @{ <p>hi</p> }\n');
fs.writeFileSync(path.join(root, 'src', 'util.ios.ts'), "export const platform = 'ios';\n");
fs.writeFileSync(path.join(root, 'src', 'util.android.ts'), "export const platform = 'android';\n");
return root;
}

function installFakeTsrxPlugin(root: string): void {
const pkgDir = path.join(root, 'node_modules', '@tsrx', 'typescript-plugin');
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@tsrx/typescript-plugin', version: '0.0.0', bin: { 'tsrx-tsc': './tsc.js' } }));
fs.writeFileSync(path.join(pkgDir, 'tsc.js'), FAKE_TSRX_TSC);
}

async function runCheck(opts: Parameters<typeof typescriptCheckPlugin>[0]): Promise<void> {
const plugin = typescriptCheckPlugin(opts);
await (plugin.buildStart as unknown as () => Promise<void>)();
}

function readRun(outFile: string): RecordedRun {
return JSON.parse(fs.readFileSync(outFile, 'utf8'));
}

function baseNames(run: RecordedRun): string[] {
return run.config.files.map((file) => path.basename(file));
}

describe('typescriptCheckPlugin with .tsrx imports', () => {
let cwd: string;
let root: string;
let outFile: string;

beforeEach(() => {
cwd = process.cwd();
root = createProject();
outFile = path.join(root, 'tsrx-tsc-run.json');
process.env.NS_TSRX_TEST_OUT = outFile;
process.chdir(root);
});

afterEach(() => {
process.chdir(cwd);
delete process.env.NS_TSRX_TEST_OUT;
delete process.env.NS_TSRX_TEST_EXIT;
vi.restoreAllMocks();
fs.rmSync(root, { recursive: true, force: true });
});

it('delegates to tsrx-tsc with a platform-filtered config inside the project', async () => {
installFakeTsrxPlugin(root);

await runCheck({ platform: 'ios' });

const run = readRun(outFile);
expect(run.argv).toEqual(['--pretty', '-p', run.configPath]);
expect(run.configPath.startsWith(path.join(root, 'node_modules', '.ns-vite') + path.sep)).toBe(true);
expect(run.config.extends).toBe(path.join(root, 'tsconfig.json'));
expect(baseNames(run)).toEqual(expect.arrayContaining(['main.ts', 'util.ios.ts']));
expect(baseNames(run)).not.toContain('util.android.ts');
expect(run.config.compilerOptions.moduleSuffixes).toEqual(['.ios', '.native', '']);
expect(run.config.tsrx).toEqual({ platform: 'ios' });
expect(fs.existsSync(path.dirname(run.configPath))).toBe(false);
});

it('selects the android files and maps visionOS onto the ios tsrx platform', async () => {
installFakeTsrxPlugin(root);

await runCheck({ platform: 'android' });
const android = readRun(outFile);
expect(baseNames(android)).toContain('util.android.ts');
expect(baseNames(android)).not.toContain('util.ios.ts');
expect(android.config.tsrx).toEqual({ platform: 'android' });

await runCheck({ platform: 'visionos' });
const visionos = readRun(outFile);
expect(visionos.config.tsrx).toEqual({ platform: 'ios' });
expect(visionos.config.compilerOptions.moduleSuffixes).toEqual(['.visionos', '.ios', '.native', '']);
});

it('leaves tsrx.platform to the project when no platform is selected', async () => {
installFakeTsrxPlugin(root);

await runCheck({});

expect(readRun(outFile).config.tsrx).toBeUndefined();
});

it('fails the build when tsrx-tsc reports errors and removes its scratch config', async () => {
installFakeTsrxPlugin(root);
process.env.NS_TSRX_TEST_EXIT = '2';
vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(runCheck({ platform: 'ios' })).rejects.toThrow('tsrx-tsc reported type errors');

expect(fs.readdirSync(path.join(root, 'node_modules', '.ns-vite'))).toEqual([]);
});

it('hints at @tsrx/typescript-plugin when the in-process check cannot resolve .tsrx imports', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

await runCheck({ platform: 'ios', failOnError: false });

expect(warn.mock.calls.flat().join('\n')).toContain('install @tsrx/typescript-plugin');
expect(fs.existsSync(outFile)).toBe(false);
expect(fs.existsSync(path.join(root, 'node_modules', '.ns-vite'))).toBe(false);
});
});
148 changes: 119 additions & 29 deletions packages/vite/helpers/typescript-check.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Plugin } from 'vite';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import type * as TS from 'typescript';
import { getCliFlags, resolvePlatform } from './cli-flags.js';
import { getProjectTSConfigPath } from './project.js';
import { getProjectRootPath, getProjectTSConfigPath } from './project.js';
import type { Platform } from './platform-types.js';
import { loadTypeScript, type TypeScript } from './typescript.js';

Expand Down Expand Up @@ -66,6 +67,113 @@ function getVueTscBinPath(): string {
return path.resolve(path.dirname(pkgPath), 'bin/vue-tsc.js');
}

function getTsrxTscBinPath(): string | undefined {
try {
const projectRequire = createRequire(path.join(getProjectRootPath(), 'package.json'));
const pkgPath = projectRequire.resolve('@tsrx/typescript-plugin/package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin?: string | Record<string, string> };
const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['tsrx-tsc'];
return bin ? path.resolve(path.dirname(pkgPath), bin) : undefined;
} catch {
return undefined;
}
}

function isTsrxFile(fileName: string): boolean {
return fileName.toLowerCase().endsWith('.tsrx');
}

function hasTsrxImports(ts: TypeScript, fileNames: readonly string[]): boolean {
return fileNames.some((fileName) => {
if (isTsrxFile(fileName)) {
return true;
}

const source = ts.sys.readFile(fileName);
return source ? ts.preProcessFile(source).importedFiles.some((file) => isTsrxFile(file.fileName)) : false;
});
}

function hasUnresolvedTsrxImport(ts: TypeScript, diagnostics: readonly TS.Diagnostic[]): boolean {
return diagnostics.some((diagnostic) => diagnostic.code === 2307 && /\.tsrx['"]/.test(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')));
}

function getTsrxPlatform(platform: PlatformType | undefined): 'ios' | 'android' | undefined {
switch (platform) {
case 'android':
return 'android';
case 'ios':
case 'visionos':
return 'ios';
default:
return undefined;
}
}

function createTsrxCheckConfig(tsConfigPath: string, parsedConfig: TS.ParsedCommandLine, rootNames: readonly string[], platform: PlatformType | undefined): object {
const references = parsedConfig.projectReferences?.map(({ path: referencePath, prepend, circular }) => ({ path: referencePath, prepend, circular }));
const tsrxPlatform = getTsrxPlatform(platform);

return {
extends: path.resolve(tsConfigPath),
files: rootNames,
include: [],
...(references?.length ? { references } : {}),
// The nearest tsconfig layer wins over the project's tsrx.platform.
...(tsrxPlatform ? { tsrx: { platform: tsrxPlatform } } : {}),
compilerOptions: {
noEmit: true,
incremental: false,
moduleSuffixes: getModuleSuffixes(platform),
},
};
}

function runTsrxTypeCheck(binPath: string, opts: { platform?: PlatformType; verbose?: boolean; logDiagnostics?: boolean }, tsConfigPath: string, parsedConfig: TS.ParsedCommandLine, rootNames: readonly string[], failOnError: boolean): void {
// TypeScript resolves typeRoots and `types` entries from the config's directory.
const scratchRoot = path.join(getProjectRootPath(), 'node_modules', '.ns-vite');
fs.mkdirSync(scratchRoot, { recursive: true });
const tempDir = fs.mkdtempSync(path.join(scratchRoot, 'tsrx-check-'));
try {
const configPath = path.join(tempDir, 'tsconfig.json');
fs.writeFileSync(configPath, JSON.stringify(createTsrxCheckConfig(tsConfigPath, parsedConfig, rootNames, opts.platform)));
runTscBinary('tsrx-tsc', [binPath, '--pretty', '-p', configPath], opts, failOnError);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}

function runTscBinary(tool: string, args: string[], opts: { verbose?: boolean; logDiagnostics?: boolean }, failOnError: boolean): void {
const result = spawnSync(process.execPath, args, {
cwd: process.cwd(),
env: { ...process.env, FORCE_COLOR: '1' },
encoding: 'utf8',
});

if (result.error) {
throw result.error;
}

if (result.status === 0) {
if (opts.verbose) {
console.log(`[ns-vite] ${tool} check passed.`);
}
return;
}

const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
if (output && opts.logDiagnostics !== false) {
(failOnError ? console.error : console.warn)(output);
}

if (!failOnError) {
console.warn(`[ns-vite] ${tool} reported type errors; continuing build because tsconfig does not require failing on type errors.`);
return;
}

throw new Error(`[ns-vite] ${tool} reported type errors.`);
}

function coerceBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') {
return value;
Expand Down Expand Up @@ -288,6 +396,12 @@ export function typescriptCheckPlugin(opts: { platform?: PlatformType; verbose?:
const parsedConfig = getParsedConfig(ts, tsConfigPath, opts.platform);
const failOnError = shouldFailOnTypeCheckError(opts, parsedConfig);
const rootNames = parsedConfig.fileNames.filter((fileName) => !shouldSkipFileForPlatform(fileName, opts.platform));
const tsrxTscBinPath = getTsrxTscBinPath();
if (tsrxTscBinPath && hasTsrxImports(ts, rootNames)) {
runTsrxTypeCheck(tsrxTscBinPath, opts, tsConfigPath, parsedConfig, rootNames, failOnError);
return;
}

const program = ts.createProgram({
rootNames,
options: parsedConfig.options,
Expand All @@ -303,6 +417,9 @@ export function typescriptCheckPlugin(opts: { platform?: PlatformType; verbose?:
}

if (opts.logDiagnostics !== false) {
if (hasUnresolvedTsrxImport(ts, diagnostics)) {
console.warn('[ns-vite] Unresolved .tsrx imports: install @tsrx/typescript-plugin so the type check can run through tsrx-tsc.');
}
const output = ts.formatDiagnosticsWithColorAndContext(diagnostics, getFormatHost(ts));
(failOnError ? console.error : console.warn)(output);
}
Expand Down Expand Up @@ -337,34 +454,7 @@ export function vueTypeCheckPlugin(opts: { platform?: PlatformType; verbose?: bo

const vueTscBinPath = getVueTscBinPath();
const moduleSuffixes = getModuleSuffixes(opts.platform).join(',');
const result = spawnSync(process.execPath, [vueTscBinPath, '--noEmit', '--pretty', '-p', tsConfigPath, '--moduleSuffixes', moduleSuffixes], {
cwd: process.cwd(),
env: { ...process.env, FORCE_COLOR: '1' },
encoding: 'utf8',
});

if (result.error) {
throw result.error;
}

if (result.status === 0) {
if (opts.verbose) {
console.log('[ns-vite] vue-tsc check passed.');
}
return;
}

const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
if (output && opts.logDiagnostics !== false) {
(failOnError ? console.error : console.warn)(output);
}

if (!failOnError) {
console.warn('[ns-vite] vue-tsc reported type errors; continuing build because tsconfig does not require failing on type errors.');
return;
}

throw new Error('[ns-vite] vue-tsc reported type errors.');
runTscBinary('vue-tsc', [vueTscBinPath, '--noEmit', '--pretty', '-p', tsConfigPath, '--moduleSuffixes', moduleSuffixes], opts, failOnError);
},
};
}
Expand Down