Visitar URL original
fix(vite): type check TSRX imports (#11450) · NativeScript/NativeScript@11203bc · GitHub
Skip to content

Commit 11203bc

Browse files
authored
fix(vite): type check TSRX imports (#11450)
[skip ci]
1 parent 824076c commit 11203bc

3 files changed

Lines changed: 259 additions & 29 deletions

File tree

‎packages/vite/docs/framework-flavors.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ Register at module scope, before `baseConfig` can run. `baseConfig` installs the
9797

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

100+
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.
101+
100102
### Keeping a runtime package out of the dev vendor bundle
101103

102104
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.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { typescriptCheckPlugin } from './typescript-check.js';
6+
7+
const FAKE_TSRX_TSC = `
8+
const fs = require('node:fs');
9+
const configPath = process.argv[process.argv.indexOf('-p') + 1];
10+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
11+
fs.writeFileSync(process.env.NS_TSRX_TEST_OUT, JSON.stringify({ argv: process.argv.slice(2), configPath, config }));
12+
process.exit(Number(process.env.NS_TSRX_TEST_EXIT || 0));
13+
`;
14+
15+
interface RecordedRun {
16+
argv: string[];
17+
configPath: string;
18+
config: { extends: string; files: string[]; tsrx?: { platform: string }; compilerOptions: { moduleSuffixes: string[] } };
19+
}
20+
21+
function createProject(): string {
22+
const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ns-vite-tsrx-check-'));
23+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'tsrx-check-app', private: true }));
24+
fs.writeFileSync(
25+
path.join(root, 'tsconfig.json'),
26+
JSON.stringify({
27+
compilerOptions: { target: 'es2020', module: 'esnext', moduleResolution: 'bundler', strict: true, noEmit: true, noEmitOnError: true, skipLibCheck: true, lib: ['es2020'], types: [] },
28+
include: ['src/**/*.ts'],
29+
}),
30+
);
31+
fs.mkdirSync(path.join(root, 'src'));
32+
fs.writeFileSync(path.join(root, 'src', 'main.ts'), "import { Greeting } from './Greeting.tsrx';\nexport const el = Greeting();\n");
33+
fs.writeFileSync(path.join(root, 'src', 'Greeting.tsrx'), 'export function Greeting() @{ <p>hi</p> }\n');
34+
fs.writeFileSync(path.join(root, 'src', 'util.ios.ts'), "export const platform = 'ios';\n");
35+
fs.writeFileSync(path.join(root, 'src', 'util.android.ts'), "export const platform = 'android';\n");
36+
return root;
37+
}
38+
39+
function installFakeTsrxPlugin(root: string): void {
40+
const pkgDir = path.join(root, 'node_modules', '@tsrx', 'typescript-plugin');
41+
fs.mkdirSync(pkgDir, { recursive: true });
42+
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@tsrx/typescript-plugin', version: '0.0.0', bin: { 'tsrx-tsc': './tsc.js' } }));
43+
fs.writeFileSync(path.join(pkgDir, 'tsc.js'), FAKE_TSRX_TSC);
44+
}
45+
46+
async function runCheck(opts: Parameters<typeof typescriptCheckPlugin>[0]): Promise<void> {
47+
const plugin = typescriptCheckPlugin(opts);
48+
await (plugin.buildStart as unknown as () => Promise<void>)();
49+
}
50+
51+
function readRun(outFile: string): RecordedRun {
52+
return JSON.parse(fs.readFileSync(outFile, 'utf8'));
53+
}
54+
55+
function baseNames(run: RecordedRun): string[] {
56+
return run.config.files.map((file) => path.basename(file));
57+
}
58+
59+
describe('typescriptCheckPlugin with .tsrx imports', () => {
60+
let cwd: string;
61+
let root: string;
62+
let outFile: string;
63+
64+
beforeEach(() => {
65+
cwd = process.cwd();
66+
root = createProject();
67+
outFile = path.join(root, 'tsrx-tsc-run.json');
68+
process.env.NS_TSRX_TEST_OUT = outFile;
69+
process.chdir(root);
70+
});
71+
72+
afterEach(() => {
73+
process.chdir(cwd);
74+
delete process.env.NS_TSRX_TEST_OUT;
75+
delete process.env.NS_TSRX_TEST_EXIT;
76+
vi.restoreAllMocks();
77+
fs.rmSync(root, { recursive: true, force: true });
78+
});
79+
80+
it('delegates to tsrx-tsc with a platform-filtered config inside the project', async () => {
81+
installFakeTsrxPlugin(root);
82+
83+
await runCheck({ platform: 'ios' });
84+
85+
const run = readRun(outFile);
86+
expect(run.argv).toEqual(['--pretty', '-p', run.configPath]);
87+
expect(run.configPath.startsWith(path.join(root, 'node_modules', '.ns-vite') + path.sep)).toBe(true);
88+
expect(run.config.extends).toBe(path.join(root, 'tsconfig.json'));
89+
expect(baseNames(run)).toEqual(expect.arrayContaining(['main.ts', 'util.ios.ts']));
90+
expect(baseNames(run)).not.toContain('util.android.ts');
91+
expect(run.config.compilerOptions.moduleSuffixes).toEqual(['.ios', '.native', '']);
92+
expect(run.config.tsrx).toEqual({ platform: 'ios' });
93+
expect(fs.existsSync(path.dirname(run.configPath))).toBe(false);
94+
});
95+
96+
it('selects the android files and maps visionOS onto the ios tsrx platform', async () => {
97+
installFakeTsrxPlugin(root);
98+
99+
await runCheck({ platform: 'android' });
100+
const android = readRun(outFile);
101+
expect(baseNames(android)).toContain('util.android.ts');
102+
expect(baseNames(android)).not.toContain('util.ios.ts');
103+
expect(android.config.tsrx).toEqual({ platform: 'android' });
104+
105+
await runCheck({ platform: 'visionos' });
106+
const visionos = readRun(outFile);
107+
expect(visionos.config.tsrx).toEqual({ platform: 'ios' });
108+
expect(visionos.config.compilerOptions.moduleSuffixes).toEqual(['.visionos', '.ios', '.native', '']);
109+
});
110+
111+
it('leaves tsrx.platform to the project when no platform is selected', async () => {
112+
installFakeTsrxPlugin(root);
113+
114+
await runCheck({});
115+
116+
expect(readRun(outFile).config.tsrx).toBeUndefined();
117+
});
118+
119+
it('fails the build when tsrx-tsc reports errors and removes its scratch config', async () => {
120+
installFakeTsrxPlugin(root);
121+
process.env.NS_TSRX_TEST_EXIT = '2';
122+
vi.spyOn(console, 'error').mockImplementation(() => {});
123+
124+
await expect(runCheck({ platform: 'ios' })).rejects.toThrow('tsrx-tsc reported type errors');
125+
126+
expect(fs.readdirSync(path.join(root, 'node_modules', '.ns-vite'))).toEqual([]);
127+
});
128+
129+
it('hints at @tsrx/typescript-plugin when the in-process check cannot resolve .tsrx imports', async () => {
130+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
131+
132+
await runCheck({ platform: 'ios', failOnError: false });
133+
134+
expect(warn.mock.calls.flat().join('\n')).toContain('install @tsrx/typescript-plugin');
135+
expect(fs.existsSync(outFile)).toBe(false);
136+
expect(fs.existsSync(path.join(root, 'node_modules', '.ns-vite'))).toBe(false);
137+
});
138+
});

‎packages/vite/helpers/typescript-check.ts‎

Lines changed: 119 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { Plugin } from 'vite';
22
import { spawnSync } from 'node:child_process';
3+
import fs from 'node:fs';
34
import { createRequire } from 'node:module';
45
import path from 'node:path';
56
import type * as TS from 'typescript';
67
import { getCliFlags, resolvePlatform } from './cli-flags.js';
7-
import { getProjectTSConfigPath } from './project.js';
8+
import { getProjectRootPath, getProjectTSConfigPath } from './project.js';
89
import type { Platform } from './platform-types.js';
910
import { loadTypeScript, type TypeScript } from './typescript.js';
1011

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

70+
function getTsrxTscBinPath(): string | undefined {
71+
try {
72+
const projectRequire = createRequire(path.join(getProjectRootPath(), 'package.json'));
73+
const pkgPath = projectRequire.resolve('@tsrx/typescript-plugin/package.json');
74+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin?: string | Record<string, string> };
75+
const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['tsrx-tsc'];
76+
return bin ? path.resolve(path.dirname(pkgPath), bin) : undefined;
77+
} catch {
78+
return undefined;
79+
}
80+
}
81+
82+
function isTsrxFile(fileName: string): boolean {
83+
return fileName.toLowerCase().endsWith('.tsrx');
84+
}
85+
86+
function hasTsrxImports(ts: TypeScript, fileNames: readonly string[]): boolean {
87+
return fileNames.some((fileName) => {
88+
if (isTsrxFile(fileName)) {
89+
return true;
90+
}
91+
92+
const source = ts.sys.readFile(fileName);
93+
return source ? ts.preProcessFile(source).importedFiles.some((file) => isTsrxFile(file.fileName)) : false;
94+
});
95+
}
96+
97+
function hasUnresolvedTsrxImport(ts: TypeScript, diagnostics: readonly TS.Diagnostic[]): boolean {
98+
return diagnostics.some((diagnostic) => diagnostic.code === 2307 && /\.tsrx['"]/.test(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')));
99+
}
100+
101+
function getTsrxPlatform(platform: PlatformType | undefined): 'ios' | 'android' | undefined {
102+
switch (platform) {
103+
case 'android':
104+
return 'android';
105+
case 'ios':
106+
case 'visionos':
107+
return 'ios';
108+
default:
109+
return undefined;
110+
}
111+
}
112+
113+
function createTsrxCheckConfig(tsConfigPath: string, parsedConfig: TS.ParsedCommandLine, rootNames: readonly string[], platform: PlatformType | undefined): object {
114+
const references = parsedConfig.projectReferences?.map(({ path: referencePath, prepend, circular }) => ({ path: referencePath, prepend, circular }));
115+
const tsrxPlatform = getTsrxPlatform(platform);
116+
117+
return {
118+
extends: path.resolve(tsConfigPath),
119+
files: rootNames,
120+
include: [],
121+
...(references?.length ? { references } : {}),
122+
// The nearest tsconfig layer wins over the project's tsrx.platform.
123+
...(tsrxPlatform ? { tsrx: { platform: tsrxPlatform } } : {}),
124+
compilerOptions: {
125+
noEmit: true,
126+
incremental: false,
127+
moduleSuffixes: getModuleSuffixes(platform),
128+
},
129+
};
130+
}
131+
132+
function runTsrxTypeCheck(binPath: string, opts: { platform?: PlatformType; verbose?: boolean; logDiagnostics?: boolean }, tsConfigPath: string, parsedConfig: TS.ParsedCommandLine, rootNames: readonly string[], failOnError: boolean): void {
133+
// TypeScript resolves typeRoots and `types` entries from the config's directory.
134+
const scratchRoot = path.join(getProjectRootPath(), 'node_modules', '.ns-vite');
135+
fs.mkdirSync(scratchRoot, { recursive: true });
136+
const tempDir = fs.mkdtempSync(path.join(scratchRoot, 'tsrx-check-'));
137+
try {
138+
const configPath = path.join(tempDir, 'tsconfig.json');
139+
fs.writeFileSync(configPath, JSON.stringify(createTsrxCheckConfig(tsConfigPath, parsedConfig, rootNames, opts.platform)));
140+
runTscBinary('tsrx-tsc', [binPath, '--pretty', '-p', configPath], opts, failOnError);
141+
} finally {
142+
fs.rmSync(tempDir, { recursive: true, force: true });
143+
}
144+
}
145+
146+
function runTscBinary(tool: string, args: string[], opts: { verbose?: boolean; logDiagnostics?: boolean }, failOnError: boolean): void {
147+
const result = spawnSync(process.execPath, args, {
148+
cwd: process.cwd(),
149+
env: { ...process.env, FORCE_COLOR: '1' },
150+
encoding: 'utf8',
151+
});
152+
153+
if (result.error) {
154+
throw result.error;
155+
}
156+
157+
if (result.status === 0) {
158+
if (opts.verbose) {
159+
console.log(`[ns-vite] ${tool} check passed.`);
160+
}
161+
return;
162+
}
163+
164+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
165+
if (output && opts.logDiagnostics !== false) {
166+
(failOnError ? console.error : console.warn)(output);
167+
}
168+
169+
if (!failOnError) {
170+
console.warn(`[ns-vite] ${tool} reported type errors; continuing build because tsconfig does not require failing on type errors.`);
171+
return;
172+
}
173+
174+
throw new Error(`[ns-vite] ${tool} reported type errors.`);
175+
}
176+
69177
function coerceBoolean(value: unknown): boolean | undefined {
70178
if (typeof value === 'boolean') {
71179
return value;
@@ -288,6 +396,12 @@ export function typescriptCheckPlugin(opts: { platform?: PlatformType; verbose?:
288396
const parsedConfig = getParsedConfig(ts, tsConfigPath, opts.platform);
289397
const failOnError = shouldFailOnTypeCheckError(opts, parsedConfig);
290398
const rootNames = parsedConfig.fileNames.filter((fileName) => !shouldSkipFileForPlatform(fileName, opts.platform));
399+
const tsrxTscBinPath = getTsrxTscBinPath();
400+
if (tsrxTscBinPath && hasTsrxImports(ts, rootNames)) {
401+
runTsrxTypeCheck(tsrxTscBinPath, opts, tsConfigPath, parsedConfig, rootNames, failOnError);
402+
return;
403+
}
404+
291405
const program = ts.createProgram({
292406
rootNames,
293407
options: parsedConfig.options,
@@ -303,6 +417,9 @@ export function typescriptCheckPlugin(opts: { platform?: PlatformType; verbose?:
303417
}
304418

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

338455
const vueTscBinPath = getVueTscBinPath();
339456
const moduleSuffixes = getModuleSuffixes(opts.platform).join(',');
340-
const result = spawnSync(process.execPath, [vueTscBinPath, '--noEmit', '--pretty', '-p', tsConfigPath, '--moduleSuffixes', moduleSuffixes], {
341-
cwd: process.cwd(),
342-
env: { ...process.env, FORCE_COLOR: '1' },
343-
encoding: 'utf8',
344-
});
345-
346-
if (result.error) {
347-
throw result.error;
348-
}
349-
350-
if (result.status === 0) {
351-
if (opts.verbose) {
352-
console.log('[ns-vite] vue-tsc check passed.');
353-
}
354-
return;
355-
}
356-
357-
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
358-
if (output && opts.logDiagnostics !== false) {
359-
(failOnError ? console.error : console.warn)(output);
360-
}
361-
362-
if (!failOnError) {
363-
console.warn('[ns-vite] vue-tsc reported type errors; continuing build because tsconfig does not require failing on type errors.');
364-
return;
365-
}
366-
367-
throw new Error('[ns-vite] vue-tsc reported type errors.');
457+
runTscBinary('vue-tsc', [vueTscBinPath, '--noEmit', '--pretty', '-p', tsConfigPath, '--moduleSuffixes', moduleSuffixes], opts, failOnError);
368458
},
369459
};
370460
}

0 commit comments

Comments
 (0)