Skip to content

Commit d6caa43

Browse files
authored
feat(vite): support custom android activites and application classes (#11043)
1 parent f5db360 commit d6caa43

File tree

5 files changed

+449
-4
lines changed

5 files changed

+449
-4
lines changed

packages/vite/configuration/base.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { createNativeClassTransformerPlugin } from '../helpers/nativeclass-trans
3434
import { getThemeCoreGenericAliases, createEnsureHoistedThemeLinkPlugin, createThemeCoreCssFallbackPlugin } from '../helpers/theme-core-plugins.js';
3535
import { createPostCssConfig } from '../helpers/postcss-platform-config.js';
3636
import { getProjectAppPath, getProjectAppRelativePath } from '../helpers/utils.js';
37+
import { appComponentsPlugin } from '../helpers/app-components.js';
3738
// Load HMR plugins lazily to avoid compiling dev-only sources during library build
3839
// This prevents TypeScript from traversing the heavy HMR implementation graph when not needed
3940
// function getHMRPluginsSafe(opts: {
@@ -342,6 +343,8 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }):
342343
NativeScriptPlugin({ platform }),
343344
// Ensure globals and Android activity are included early via virtual entry
344345
mainEntryPlugin({ platform, isDevMode, verbose, hmrActive }),
346+
// Handle custom Android Activity/Application components (auto-detected or configured)
347+
appComponentsPlugin({ platform, verbose }),
345348
dynamicImportPlugin(),
346349
// Transform Vite worker URLs to NativeScript format AFTER bundling
347350
workerUrlPlugin(),
@@ -439,7 +442,14 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }):
439442
// Preserve side effects for NativeScript core so classes/functions
440443
// aren't tree-shaken out inadvertently. This does NOT cause cross‑chunk duplication;
441444
// it only prevents Rollup from dropping modules it considers side‑effect free.
442-
moduleSideEffects: (id) => /node_modules[\\\/]\@nativescript[\\\/]core[\\\/]/.test(id) || null,
445+
// Also preserve side effects for .android and .ios files which may contain
446+
// other decorated classes that register with the native runtime
447+
moduleSideEffects: (id) => {
448+
if (/node_modules[\\\/]\@nativescript[\\\/]core[\\\/]/.test(id)) return true;
449+
// Activity and Application files have side effects (class registration)
450+
if (/\.(android|ios)\.(ts|js)$/.test(id)) return true;
451+
return null;
452+
},
443453
},
444454
input: 'virtual:entry-with-polyfills',
445455
output: {
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import path from 'path';
2+
import fs from 'fs';
3+
import type { Plugin, ResolvedConfig } from 'vite';
4+
import { getProjectRootPath } from './project.js';
5+
import { getProjectAppRelativePath } from './utils.js';
6+
7+
const projectRoot = getProjectRootPath();
8+
9+
export interface AppComponentsOptions {
10+
/**
11+
* List of app component paths (relative to project root).
12+
* These are typically custom Android Activity or Application classes.
13+
* Example: ['./app/custom-activity.android.ts', './app/custom-application.android.ts']
14+
*/
15+
appComponents?: string[];
16+
platform: 'android' | 'ios' | 'visionos';
17+
verbose?: boolean;
18+
}
19+
20+
/**
21+
* Get app components from environment variable or nativescript.config.ts
22+
* Format: comma-separated paths, e.g., "./app/custom-activity.android,./app/custom-application.android"
23+
*/
24+
function getAppComponentsFromEnv(): string[] {
25+
const envValue = process.env.NS_APP_COMPONENTS;
26+
if (!envValue) return [];
27+
return envValue
28+
.split(',')
29+
.map((p) => p.trim())
30+
.filter(Boolean);
31+
}
32+
33+
/**
34+
* Resolve an app component path to an absolute path
35+
*/
36+
function resolveComponentPath(componentPath: string): string | null {
37+
// If already absolute, check if exists
38+
if (path.isAbsolute(componentPath)) {
39+
return fs.existsSync(componentPath) ? componentPath : null;
40+
}
41+
42+
// Remove leading ./ if present for consistency
43+
const cleanPath = componentPath.replace(/^\.\//, '');
44+
45+
// Try with and without extensions
46+
const extensions = ['', '.ts', '.js', '.android.ts', '.android.js'];
47+
48+
for (const ext of extensions) {
49+
const fullPath = path.resolve(projectRoot, cleanPath + ext);
50+
if (fs.existsSync(fullPath)) {
51+
return fullPath;
52+
}
53+
}
54+
55+
// Also try in the app directory
56+
const appDir = getProjectAppRelativePath('');
57+
for (const ext of extensions) {
58+
const fullPath = path.resolve(projectRoot, appDir, cleanPath + ext);
59+
if (fs.existsSync(fullPath)) {
60+
return fullPath;
61+
}
62+
}
63+
64+
return null;
65+
}
66+
67+
/**
68+
* Extract the output name for an app component
69+
* e.g., "./app/custom-activity.android.ts" -> "custom-activity"
70+
* e.g., "./app/custom-application.android.ts" -> "custom-application"
71+
*/
72+
function getComponentOutputName(componentPath: string): string {
73+
const basename = path.basename(componentPath);
74+
// Remove .android.ts, .android.js, .ts, .js extensions
75+
return basename.replace(/\.(android\.)?(ts|js)$/, '');
76+
}
77+
78+
/**
79+
* Plugin to handle NativeScript app components (custom Activity/Application classes)
80+
*
81+
* These components need to be bundled as separate entry points because:
82+
* 1. Custom Android Activity classes are loaded by the Android runtime before the main bundle
83+
* 2. Custom Android Application classes are loaded even earlier in the app lifecycle
84+
*
85+
* Usage in vite.config.ts:
86+
* ```ts
87+
* import { defineConfig } from 'vite';
88+
* import { typescriptConfig, appComponentsPlugin } from '@nativescript/vite';
89+
*
90+
* export default defineConfig(({ mode }) => {
91+
* const config = typescriptConfig({ mode });
92+
* config.plugins.push(
93+
* appComponentsPlugin({
94+
* appComponents: ['./app/custom-activity.android.ts'],
95+
* platform: 'android'
96+
* })
97+
* );
98+
* return config;
99+
* });
100+
* ```
101+
*
102+
* Or via environment variable:
103+
* NS_APP_COMPONENTS="./app/custom-activity.android,./app/custom-application.android" ns run android
104+
*/
105+
export function appComponentsPlugin(options: AppComponentsOptions): Plugin {
106+
const { platform, verbose = false } = options;
107+
108+
// Collect app components from all sources
109+
let appComponents: string[] = [...(options.appComponents || []), ...getAppComponentsFromEnv()];
110+
111+
// Remove duplicates
112+
appComponents = [...new Set(appComponents)];
113+
114+
// Resolve all component paths
115+
const resolvedComponents: Map<string, { absolutePath: string; outputName: string }> = new Map();
116+
117+
for (const component of appComponents) {
118+
const absolutePath = resolveComponentPath(component);
119+
if (absolutePath) {
120+
const outputName = getComponentOutputName(absolutePath);
121+
resolvedComponents.set(component, { absolutePath, outputName });
122+
if (verbose) {
123+
console.log(`[app-components] Found: ${component} -> ${outputName}.mjs`);
124+
}
125+
} else if (verbose) {
126+
console.warn(`[app-components] Could not resolve: ${component}`);
127+
}
128+
}
129+
130+
// Skip if no components found
131+
if (resolvedComponents.size === 0) {
132+
return {
133+
name: 'nativescript-app-components',
134+
apply: 'build',
135+
};
136+
}
137+
138+
// Track component output names for entryFileNames
139+
const componentOutputNames = new Set<string>();
140+
for (const [, { outputName }] of resolvedComponents) {
141+
componentOutputNames.add(outputName);
142+
}
143+
144+
// Set environment variable so main-entry.ts can inject imports for these components
145+
// This allows the virtual module to know which app components are configured
146+
const componentPaths = Array.from(resolvedComponents.values()).map((c) => c.absolutePath);
147+
process.env.NS_APP_COMPONENTS = componentPaths.join(',');
148+
149+
// Create a set of output names for quick lookup in resolveId
150+
const outputMjsFiles = new Set<string>();
151+
const absoluteMjsPaths = new Set<string>();
152+
for (const [, { outputName }] of resolvedComponents) {
153+
outputMjsFiles.add(`~/${outputName}.mjs`);
154+
outputMjsFiles.add(`./${outputName}.mjs`);
155+
// Also track absolute paths that Vite might resolve ~/foo.mjs to
156+
const appDir = getProjectAppRelativePath('');
157+
const absoluteMjsPath = path.resolve(projectRoot, appDir, `${outputName}.mjs`);
158+
absoluteMjsPaths.add(absoluteMjsPath);
159+
}
160+
161+
let config: ResolvedConfig;
162+
163+
return {
164+
name: 'nativescript-app-components',
165+
apply: 'build',
166+
167+
configResolved(resolvedConfig) {
168+
config = resolvedConfig;
169+
},
170+
171+
// Mark app component output files as external during build
172+
// These are generated as separate entry points and will exist at runtime
173+
resolveId(id) {
174+
// Handle ~/foo.mjs or ./foo.mjs patterns
175+
if (outputMjsFiles.has(id)) {
176+
// Return the id with external flag - this tells Rollup to keep the import as-is
177+
return { id: `./${id.replace(/^~\//, '')}`, external: true };
178+
}
179+
// Handle absolute paths that Vite resolves ~/foo.mjs to (e.g., /path/to/app/foo.mjs)
180+
if (absoluteMjsPaths.has(id)) {
181+
const basename = path.basename(id);
182+
return { id: `./${basename}`, external: true };
183+
}
184+
return null;
185+
},
186+
187+
// Modify the Vite config to support multiple entry points
188+
config(userConfig) {
189+
if (resolvedComponents.size === 0) return null;
190+
191+
// We need to modify the output.entryFileNames to handle multiple entries
192+
return {
193+
build: {
194+
rollupOptions: {
195+
output: {
196+
// Use a function to determine entry file names
197+
entryFileNames: (chunkInfo: { name: string }) => {
198+
// App components should output as .mjs files
199+
// This is required because SBG (Static Binding Generator) only parses
200+
// .mjs files as ES modules. If we output as .js, SBG will try to parse
201+
// it as CommonJS and fail on import statements.
202+
if (componentOutputNames.has(chunkInfo.name)) {
203+
return `${chunkInfo.name}.mjs`;
204+
}
205+
// Default: main bundle
206+
return 'bundle.mjs';
207+
},
208+
},
209+
},
210+
},
211+
};
212+
},
213+
214+
// Modify rollup options to add additional entry points
215+
options(inputOptions) {
216+
if (resolvedComponents.size === 0) return null;
217+
218+
// Get current input
219+
const currentInput = inputOptions.input;
220+
const newInput: Record<string, string> = {};
221+
222+
// Preserve existing inputs
223+
if (typeof currentInput === 'string') {
224+
newInput['bundle'] = currentInput;
225+
} else if (Array.isArray(currentInput)) {
226+
currentInput.forEach((input, i) => {
227+
newInput[`entry${i}`] = input;
228+
});
229+
} else if (currentInput && typeof currentInput === 'object') {
230+
Object.assign(newInput, currentInput);
231+
}
232+
233+
// Add app component entries - use the actual file path directly
234+
for (const [, { absolutePath, outputName }] of resolvedComponents) {
235+
newInput[outputName] = absolutePath;
236+
}
237+
238+
if (verbose) {
239+
console.log('[app-components] Build inputs:', newInput);
240+
}
241+
242+
return { ...inputOptions, input: newInput };
243+
},
244+
245+
// Adjust output file names for app components (fallback in case entryFileNames doesn't work)
246+
generateBundle(options, bundle) {
247+
for (const [fileName, chunk] of Object.entries(bundle)) {
248+
if (chunk.type !== 'chunk') continue;
249+
250+
// Check if this is an app component entry
251+
if (componentOutputNames.has(chunk.name)) {
252+
// Rename to .mjs (SBG requires .mjs for ES module parsing)
253+
const newFileName = `${chunk.name}.mjs`;
254+
if (fileName !== newFileName) {
255+
chunk.fileName = newFileName;
256+
delete bundle[fileName];
257+
bundle[newFileName] = chunk;
258+
}
259+
}
260+
}
261+
},
262+
263+
// Post-process app component chunks to fix Rollup's internal variable renaming.
264+
// SBG (Static Binding Generator) needs the __extends and __decorate calls to use
265+
// the same class name as the outer variable assignment.
266+
renderChunk(code, chunk) {
267+
// Only process app component chunks
268+
if (!componentOutputNames.has(chunk.name)) {
269+
return null;
270+
}
271+
272+
// Look for patterns where Rollup renamed the internal class variable
273+
// Pattern: var ClassName = ... __extends(ClassName2, _super); ... return ClassName2; ...
274+
// We need: var ClassName = ... __extends(ClassName, _super); ... return ClassName; ...
275+
276+
// Use a simpler regex that matches across the various output formats
277+
// This finds: var SomeName = ... __extends(SomeName2, ...)
278+
const varAssignRegex = /var\s+(\w+)\s*=[\s\S]*?__extends\s*\(\s*(\w+)\s*,/g;
279+
280+
let match;
281+
let modifiedCode = code;
282+
283+
while ((match = varAssignRegex.exec(code)) !== null) {
284+
const outerName = match[1]; // e.g., "CustomActivity"
285+
const innerName = match[2]; // e.g., "CustomActivity2"
286+
287+
if (outerName !== innerName && innerName === outerName + '2') {
288+
// Rollup renamed it - fix by replacing all occurrences of the inner name
289+
// Only within this chunk, replace innerName with outerName
290+
// Be careful to only replace as a complete identifier
291+
const innerNameRegex = new RegExp(`\\b${innerName}\\b`, 'g');
292+
modifiedCode = modifiedCode.replace(innerNameRegex, outerName);
293+
294+
if (verbose) {
295+
console.log(`[app-components] Fixed Rollup rename: ${innerName} -> ${outerName} in ${chunk.fileName}`);
296+
}
297+
}
298+
}
299+
300+
if (modifiedCode !== code) {
301+
return { code: modifiedCode, map: null };
302+
}
303+
304+
return null;
305+
},
306+
};
307+
}
308+
309+
/**
310+
* Get resolved app components with their output file names
311+
* Used by main-entry.ts to inject imports for custom activities/applications
312+
*/
313+
export function getResolvedAppComponents(platform: string): Array<{ absolutePath: string; outputName: string }> {
314+
// Get components from environment variable (set by appComponentsPlugin during build)
315+
const components = getAppComponentsFromEnv();
316+
const resolved: Array<{ absolutePath: string; outputName: string }> = [];
317+
318+
for (const component of components) {
319+
const absolutePath = resolveComponentPath(component);
320+
if (absolutePath) {
321+
const outputName = getComponentOutputName(absolutePath);
322+
resolved.push({ absolutePath, outputName });
323+
}
324+
}
325+
326+
return resolved;
327+
}

packages/vite/helpers/main-entry.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from 'os';
44
import path from 'path';
55
import { getProjectFlavor } from './flavor.js';
66
import { getProjectAppPath, getProjectAppRelativePath, getProjectAppVirtualPath } from './utils.js';
7+
import { getResolvedAppComponents } from './app-components.js';
78
// Switched to runtime modules to avoid fragile string injection and enable TS checks
89
const projectRoot = getProjectRootPath();
910
const appRootDir = getProjectAppPath();
@@ -127,6 +128,24 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos'
127128
imports += "import 'virtual:ns-bundler-context';\n";
128129
}
129130

131+
// ---- Custom App Components (Activity/Application) ----
132+
// These must be loaded early so the JS class is registered before Android instantiates them
133+
if (opts.platform === 'android') {
134+
try {
135+
const appComponents = getResolvedAppComponents('android');
136+
for (const component of appComponents) {
137+
// The appComponentsPlugin bundles these as separate .mjs entry points
138+
// We must import the output file, not the source, since it's a separate entry
139+
imports += `import "~/${component.outputName}.mjs";\n`;
140+
if (opts.verbose) {
141+
imports += `console.info('[ns-entry] app component loaded: ${component.outputName}');\n`;
142+
}
143+
}
144+
} catch (err) {
145+
console.error('[main-entry] Error resolving app components:', err);
146+
}
147+
}
148+
130149
// ---- Platform-specific always-needed modules ----
131150
// Track if we need to defer Android activity import (non-HMR only)
132151
let needsAndroidActivityDefer = false;

0 commit comments

Comments
 (0)