From 26b74ac071df4b599fc3caa1ef14bc067b794a64 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 23 Nov 2025 00:29:11 -0600 Subject: [PATCH 01/13] feat(vite): improve workspace resolutions --- packages/vite/configuration/angular.ts | 2 +- packages/vite/configuration/base.ts | 4 ++-- packages/vite/configuration/react.ts | 2 +- packages/vite/configuration/solid.ts | 2 +- packages/vite/configuration/typescript.ts | 2 +- packages/vite/configuration/vue.ts | 2 +- packages/vite/helpers/main-entry.ts | 21 +++++++++++++++++---- 7 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/vite/configuration/angular.ts b/packages/vite/configuration/angular.ts index 2dcdbd8181..84f14b790f 100644 --- a/packages/vite/configuration/angular.ts +++ b/packages/vite/configuration/angular.ts @@ -305,7 +305,7 @@ export const angularConfig = ({ mode }): UserConfig => { const enableRollupLinker = process.env.NS_ENABLE_ROLLUP_LINKER === '1' || process.env.NS_ENABLE_ROLLUP_LINKER === 'true' || hmrActive; - return mergeConfig(baseConfig({ mode }), { + return mergeConfig(baseConfig({ mode, flavor: 'angular' }), { plugins: [...plugins, ...(enableRollupLinker ? [angularRollupLinker(process.cwd())] : []), renderChunkLinker, postLinker], // Always alias fesm2022 deep imports to package root so vendor bridge can externalize properly resolve: { diff --git a/packages/vite/configuration/base.ts b/packages/vite/configuration/base.ts index 7905256e6d..54f1f8d6d1 100644 --- a/packages/vite/configuration/base.ts +++ b/packages/vite/configuration/base.ts @@ -331,12 +331,12 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }): // Simplified CommonJS handling - let Vite's optimizeDeps do the heavy lifting commonjs({ include: [/node_modules/], - // Force specific problematic modules to be treated as CommonJS + // Let Rollup/Vite decide default mapping for CommonJS modules. requireReturnsDefault: 'auto', defaultIsModuleExports: 'auto', transformMixedEsModules: true, // Ignore optional dependencies that are meant to fail gracefully - ignore: ['@nativescript/android', '@nativescript/ios'], + ignore: ['@nativescript/android', '@nativescript/ios', '@nativescript/visionos'], }), nsConfigAsJsonPlugin(), NativeScriptPlugin({ platform }), diff --git a/packages/vite/configuration/react.ts b/packages/vite/configuration/react.ts index cd57154f07..70e2cb56fc 100644 --- a/packages/vite/configuration/react.ts +++ b/packages/vite/configuration/react.ts @@ -86,7 +86,7 @@ export default React; ]; export const reactConfig = ({ mode }): UserConfig => { - return mergeConfig(baseConfig({ mode }), { + return mergeConfig(baseConfig({ mode, flavor: 'react' }), { plugins, }); }; diff --git a/packages/vite/configuration/solid.ts b/packages/vite/configuration/solid.ts index 8397296441..2513f00b13 100644 --- a/packages/vite/configuration/solid.ts +++ b/packages/vite/configuration/solid.ts @@ -47,7 +47,7 @@ const plugins = [ ]; export const solidConfig = ({ mode }): UserConfig => { - return mergeConfig(baseConfig({ mode }), { + return mergeConfig(baseConfig({ mode, flavor: 'solid' }), { plugins, }); }; diff --git a/packages/vite/configuration/typescript.ts b/packages/vite/configuration/typescript.ts index bee76cf8fc..9987c562c5 100644 --- a/packages/vite/configuration/typescript.ts +++ b/packages/vite/configuration/typescript.ts @@ -154,7 +154,7 @@ function createXmlLoaderPlugin(): Plugin { } export const typescriptConfig = ({ mode }): UserConfig => { - return mergeConfig(baseConfig({ mode }), { + return mergeConfig(baseConfig({ mode, flavor: 'typescript' }), { plugins: [createXmlLoaderPlugin(), createBundlerContextPlugin()], }); }; diff --git a/packages/vite/configuration/vue.ts b/packages/vite/configuration/vue.ts index 35f980837a..4f7db8780d 100644 --- a/packages/vite/configuration/vue.ts +++ b/packages/vite/configuration/vue.ts @@ -21,7 +21,7 @@ export const vueConfig = ({ mode }): UserConfig => { const isDevMode = targetMode === 'development'; const hmrActive = isDevMode && !!cliFlags.hmr; - return mergeConfig(baseConfig({ mode }), { + return mergeConfig(baseConfig({ mode, flavor: 'vue' }), { plugins: [ { ...alias({ diff --git a/packages/vite/helpers/main-entry.ts b/packages/vite/helpers/main-entry.ts index 7e5833ffa3..583d98fca9 100644 --- a/packages/vite/helpers/main-entry.ts +++ b/packages/vite/helpers/main-entry.ts @@ -22,8 +22,21 @@ const mainEntryRelPosix = (() => { const flavor = getProjectFlavor() as string; // Optional polyfills support (non-HMR specific but dev friendly) -const polyfillsPath = getProjectFilePath(getProjectAppRelativePath('polyfills.ts')); -const polyfillsExists = fs.existsSync(polyfillsPath); +// Resolve polyfills relative to the main entry directory so it works both for standalone projects and monorepos/workspaces where the workspace root and app root differ. +// We keep both the absolute filesystem path (for existsSync) and a project-root-relative POSIX path (for the import specifier used in Vite). +const mainEntryDir = path.dirname(mainEntry); +const polyfillsFsPath = path.resolve(mainEntryDir, 'polyfills.ts'); +const polyfillsExists = fs.existsSync(polyfillsFsPath); +const polyfillsImportSpecifier = (() => { + try { + // Normalize to "/..." posix-style (similar to mainEntryRelPosix) + const rel = path.relative(projectRoot, polyfillsFsPath).replace(/\\/g, '/'); + return ('/' + rel).replace(/\/+/g, '/'); + } catch { + // Fallback to a simple relative specifier next to main entry + return './polyfills.ts'; + } +})(); const VIRTUAL_ID = 'virtual:entry-with-polyfills'; const RESOLVED = '\0' + VIRTUAL_ID; @@ -167,9 +180,9 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos' // ---- Optional polyfills ---- if (polyfillsExists) { - imports += `import '${polyfillsPath}';\n`; + imports += `import '${polyfillsImportSpecifier}';\n`; if (opts.verbose) { - imports += `console.info('[ns-entry] polyfills imported from', ${JSON.stringify(polyfillsPath)});\n`; + imports += `console.info('[ns-entry] polyfills imported from', ${JSON.stringify(polyfillsImportSpecifier)});\n`; } } else if (opts.verbose) { imports += "console.info('[ns-entry] no polyfills file found');\n"; From 2abf7bc303bd9cb5d9ce9ae6d577bdcca38317b7 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 23 Nov 2025 23:25:37 -0600 Subject: [PATCH 02/13] fix(vite): windows path handling --- packages/vite/hmr/server/vite-plugin.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/vite/hmr/server/vite-plugin.ts b/packages/vite/hmr/server/vite-plugin.ts index 90e325cd5b..67eb82d209 100644 --- a/packages/vite/hmr/server/vite-plugin.ts +++ b/packages/vite/hmr/server/vite-plugin.ts @@ -1,5 +1,6 @@ import type { Plugin, ResolvedConfig } from 'vite'; import { createRequire } from 'node:module'; +import path from 'path'; const require = createRequire(import.meta.url); const VIRTUAL_ID = 'virtual:ns-hmr-client'; @@ -20,7 +21,23 @@ export function nsHmrClientVitePlugin(opts: { platform: string; verbose?: boolea load(id) { if (id !== RESOLVED_ID) return null; - const clientPath = require.resolve('@nativescript/vite/hmr/client/index.js'); + /** + * Use a POSIX-style import specifier for the client entry to avoid + * Windows drive-letter paths (e.g. "D:\\...") accidentally + * becoming bare import ids that Rollup/Vite cannot resolve. + * We still resolve the real filesystem path for correctness, but convert it to a project-relative POSIX path before interpolating it into the generated module. + **/ + const clientFsPath = require.resolve('@nativescript/vite/hmr/client/index.js'); + // Prefer project root when available; otherwise fall back to cwd. + const projectRoot = config?.root || process.cwd(); + let clientImport = clientFsPath; + try { + const rel = path.relative(projectRoot, clientFsPath).replace(/\\/g, '/'); + clientImport = (rel.startsWith('.') ? rel : `/${rel}`).replace(/\/+/g, '/'); + } catch { + // On any failure, keep the original path but normalize to POSIX + clientImport = clientFsPath.replace(/\\/g, '/'); + } // Build ws url from Vite server info let host = process.env.NS_HMR_HOST || (config?.server?.host as any); @@ -38,7 +55,7 @@ export function nsHmrClientVitePlugin(opts: { platform: string; verbose?: boolea // Import client and start it with explicit ws URL const banner = opts.verbose ? `console.log('[ns-hmr-client] starting client -> ${wsUrl} (HTTP loader enabled via __NS_HTTP_ORIGIN__)');` : ''; return ` -import startViteHMR from "${clientPath}"; +import startViteHMR from "${clientImport}"; ${banner} startViteHMR({ wsUrl: ${JSON.stringify(wsUrl)} }); `; From 03f7d266fe09b147daa3e951d9ce851ed075592b Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 24 Nov 2025 22:42:35 -0600 Subject: [PATCH 03/13] fix: cross platform file paths --- packages/vite/helpers/css-tree.ts | 1 - .../vite/hmr/server/vite-plugin-path.spec.ts | 64 +++++++++++++++++++ packages/vite/hmr/server/vite-plugin.ts | 18 +++++- 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/vite/hmr/server/vite-plugin-path.spec.ts diff --git a/packages/vite/helpers/css-tree.ts b/packages/vite/helpers/css-tree.ts index 271fcb7d50..052987ef59 100644 --- a/packages/vite/helpers/css-tree.ts +++ b/packages/vite/helpers/css-tree.ts @@ -2,7 +2,6 @@ import path from 'path'; import { __dirname } from './project.js'; export const aliasCssTree = [ - // Node.js built-ins and mdn-data polyfills for css-tree { find: 'module', replacement: path.resolve(__dirname, '../polyfills/module.js'), diff --git a/packages/vite/hmr/server/vite-plugin-path.spec.ts b/packages/vite/hmr/server/vite-plugin-path.spec.ts new file mode 100644 index 0000000000..367347a799 --- /dev/null +++ b/packages/vite/hmr/server/vite-plugin-path.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import path from 'path'; +import { pathToFileURL } from 'url'; + +// Reproduce the path logic from vite-plugin.ts in a testable helper +function computeClientImport(options: { projectRoot: string; clientFsPath: string }) { + const { projectRoot, clientFsPath } = options; + let clientImport = clientFsPath; + try { + const rel = path.relative(projectRoot, clientFsPath); + const relPosix = rel.replace(/\\/g, '/'); + + if (path.isAbsolute(rel)) { + clientImport = pathToFileURL(clientFsPath).toString(); + } else { + clientImport = (relPosix.startsWith('.') ? relPosix : `/${relPosix}`).replace(/\/+/g, '/'); + } + } catch { + clientImport = clientFsPath.replace(/\\/g, '/'); + } + return clientImport; +} + +describe('ns-hmr-client vite plugin path handling', () => { + it('keeps a clean project-relative POSIX path on POSIX-like roots', () => { + const projectRoot = '/Users/test/app'; + const clientFsPath = '/Users/test/app/node_modules/@nativescript/vite/hmr/client/index.js'; + + const result = computeClientImport({ projectRoot, clientFsPath }); + + expect(result).toBe('/node_modules/@nativescript/vite/hmr/client/index.js'); + }); + + it('falls back to file URL when relative becomes absolute (Windows-like different drive)', () => { + // Simulate a scenario where projectRoot and clientFsPath are on different drives. + // On real Windows, path.relative('C:/proj', 'D:/lib/file.js') is an absolute path + // starting with the target drive (e.g. 'D:/lib/file.js'). + const projectRoot = 'C:/project/root'; + const clientFsPath = 'D:/ns-vite-demo/node_modules/@nativescript/vite/hmr/client/index.js'; + + const result = computeClientImport({ projectRoot, clientFsPath }); + + // On non-Windows hosts, Node's path.relative may not simulate the + // cross-drive behavior. The important contract is: when the computed + // relative path is absolute, we do NOT generate an import like '/D:/...' + // that would later resolve to 'D:\\D:\\...'. In that case we use a + // file URL; otherwise we leave the relative specifier alone. + if (path.sep === '\\') { + // Windows: expect file URL behavior + expect(result.startsWith('file://')).toBe(true); + } + expect(result.includes('nativescript/vite/hmr/client/index.js')).toBe(true); + }); + + it('handles Windows-style same-drive paths as project-relative POSIX', () => { + const projectRoot = 'D:/ns-vite-demo'; + const clientFsPath = 'D:/ns-vite-demo/node_modules/@nativescript/vite/hmr/client/index.js'; + + const result = computeClientImport({ projectRoot, clientFsPath }); + + // When on the same drive, relative path should be node_modules/... and we normalize to POSIX. + expect(result).toBe('/node_modules/@nativescript/vite/hmr/client/index.js'); + }); +}); diff --git a/packages/vite/hmr/server/vite-plugin.ts b/packages/vite/hmr/server/vite-plugin.ts index 67eb82d209..c7465e43d7 100644 --- a/packages/vite/hmr/server/vite-plugin.ts +++ b/packages/vite/hmr/server/vite-plugin.ts @@ -1,6 +1,7 @@ import type { Plugin, ResolvedConfig } from 'vite'; import { createRequire } from 'node:module'; import path from 'path'; +import { pathToFileURL } from 'url'; const require = createRequire(import.meta.url); const VIRTUAL_ID = 'virtual:ns-hmr-client'; @@ -32,8 +33,21 @@ export function nsHmrClientVitePlugin(opts: { platform: string; verbose?: boolea const projectRoot = config?.root || process.cwd(); let clientImport = clientFsPath; try { - const rel = path.relative(projectRoot, clientFsPath).replace(/\\/g, '/'); - clientImport = (rel.startsWith('.') ? rel : `/${rel}`).replace(/\/+/g, '/'); + // Compute a project-relative POSIX path when possible. When `path.relative` + // returns an absolute path (this can occur on Windows if roots differ or + // when path.relative returns a drive-letter-prefixed path), avoid creating + // a specifier like `/D:/...` which later gets resolved to `D:\D:\...`. + const rel = path.relative(projectRoot, clientFsPath); + const relPosix = rel.replace(/\\/g, '/'); + + // If `rel` is absolute (e.g. starts with a drive letter on Windows), + // use a file:// URL for the import so Vite/Rollup do not prepend the + // project root and cause duplicated drive prefixes. + if (path.isAbsolute(rel)) { + clientImport = pathToFileURL(clientFsPath).toString(); + } else { + clientImport = (relPosix.startsWith('.') ? relPosix : `/${relPosix}`).replace(/\/+/g, '/'); + } } catch { // On any failure, keep the original path but normalize to POSIX clientImport = clientFsPath.replace(/\\/g, '/'); From 40788e379040e9d6af39c32e41e6dbcb1c8ed573 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 4 Dec 2025 11:17:10 -0800 Subject: [PATCH 04/13] chore: dep cleanup --- apps/automated/package.json | 8 +- apps/toolbox/package.json | 11 +- apps/toolbox/project.json | 2 +- apps/ui/package.json | 10 +- package-lock.json | 542 ++++++++++++++------ package.json | 7 +- packages/vite/bin/cli.cjs | 0 packages/vite/hmr/shared/vendor/manifest.ts | 21 +- packages/webpack5/package.json | 2 +- 9 files changed, 408 insertions(+), 195 deletions(-) mode change 100644 => 100755 packages/vite/bin/cli.cjs diff --git a/apps/automated/package.json b/apps/automated/package.json index a5602ba461..e2f41c27b5 100644 --- a/apps/automated/package.json +++ b/apps/automated/package.json @@ -11,13 +11,13 @@ "nativescript-theme-core": "file:../../node_modules/nativescript-theme-core" }, "devDependencies": { - "@nativescript/android": "alpha", - "@nativescript/ios": "alpha", - "@nativescript/visionos": "~8.9.0", + "@nativescript/android": "~9.0.0", + "@nativescript/ios": "~9.0.0", + "@nativescript/visionos": "~9.0.0", "@nativescript/vite": "file:../../dist/packages/vite", "@nativescript/webpack": "file:../../dist/packages/webpack5", "circular-dependency-plugin": "^5.2.2", - "typescript": "~5.8.0" + "typescript": "~5.9.3" }, "gitHead": "c06800e52ee1a184ea2dffd12a6702aaa43be4e3", "readme": "NativeScript Application" diff --git a/apps/toolbox/package.json b/apps/toolbox/package.json index 7bc8f8db39..998e74fbbb 100644 --- a/apps/toolbox/package.json +++ b/apps/toolbox/package.json @@ -9,14 +9,15 @@ "dependencies": { "@nativescript/core": "file:../../packages/core", "@nativescript/imagepicker": "^4.1.0", - "nativescript-theme-core": "file:../../node_modules/nativescript-theme-core" + "nativescript-theme-core": "file:../../node_modules/nativescript-theme-core", + "@valor/nativescript-websockets": "file:../../node_modules/@valor/nativescript-websockets" }, "devDependencies": { - "@nativescript/android": "alpha", - "@nativescript/ios": "alpha", - "@nativescript/visionos": "~8.9.0", + "@nativescript/android": "~9.0.0", + "@nativescript/ios": "~9.0.0", + "@nativescript/visionos": "~9.0.0", "@nativescript/vite": "file:../../dist/packages/vite", "@nativescript/webpack": "file:../../dist/packages/webpack5", - "typescript": "~5.8.0" + "typescript": "~5.9.3" } } diff --git a/apps/toolbox/project.json b/apps/toolbox/project.json index b0fa652e0f..3163d48e6d 100644 --- a/apps/toolbox/project.json +++ b/apps/toolbox/project.json @@ -24,7 +24,7 @@ "debug": { "executor": "@nativescript/nx:debug", "options": { - "noHmr": true, + "noHmr": false, "uglify": false, "release": false, "forDevice": false, diff --git a/apps/ui/package.json b/apps/ui/package.json index 6b6c66c562..7cf1e6e285 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -8,14 +8,14 @@ }, "dependencies": { "@nativescript/core": "file:../../packages/core", - "nativescript-theme-core": "file:../../node_modules/nativescript-theme-core" + "nativescript-theme-core": "^1.0.4" }, "devDependencies": { - "@nativescript/android": "alpha", - "@nativescript/ios": "alpha", - "@nativescript/visionos": "~8.9.0", + "@nativescript/android": "~9.0.0", + "@nativescript/ios": "~9.0.0", + "@nativescript/visionos": "~9.0.0", "@nativescript/webpack": "file:../../dist/packages/webpack5", - "typescript": "~5.8.0" + "typescript": "~5.9.3" }, "gitHead": "8ab7726d1ee9991706069c1359c552e67ee0d1a4", "readme": "NativeScript Application", diff --git a/package-lock.json b/package-lock.json index b7fc273315..bfe71524e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@valor/nativescript-websockets": "^2.0.2", "nativescript-theme-core": "^1.0.4" }, "devDependencies": { @@ -102,15 +103,172 @@ "vite-plugin-static-copy": "^3.1.4", "vitest": "^3.2.4", "vue-loader": "^15.0.0 <= 15.9.8", - "webpack": "^5.30.0 <= 5.50.0 || ^5.51.2", "webpack-bundle-analyzer": "^4.0.0", "webpack-chain": "^6.0.0", - "webpack-cli": "^4.0.0", - "webpack-merge": "^5.0.0", + "webpack-merge": "^6.0.0", "webpack-virtual-modules": "^0.4.0", "zx": "^8.3.0" } }, + "apps/automated": { + "extraneous": true, + "license": "MIT", + "dependencies": { + "@nativescript/core": "file:../../packages/core", + "nativescript-theme-core": "file:../../node_modules/nativescript-theme-core" + }, + "devDependencies": { + "@nativescript/android": "~9.0.0", + "@nativescript/ios": "~9.0.0", + "@nativescript/visionos": "~9.0.0", + "@nativescript/vite": "file:../../dist/packages/vite", + "@nativescript/webpack": "file:../../dist/packages/webpack5", + "circular-dependency-plugin": "^5.2.2", + "typescript": "~5.9.3" + } + }, + "apps/toolbox": { + "extraneous": true, + "license": "MIT", + "dependencies": { + "@nativescript/core": "file:../../packages/core", + "@nativescript/imagepicker": "^4.1.0", + "@valor/nativescript-websockets": "file:../../node_modules/@valor/nativescript-websockets", + "nativescript-theme-core": "file:../../node_modules/nativescript-theme-core" + }, + "devDependencies": { + "@nativescript/android": "~9.0.0", + "@nativescript/ios": "~9.0.0", + "@nativescript/visionos": "~9.0.0", + "@nativescript/vite": "file:../../dist/packages/vite", + "@nativescript/webpack": "file:../../dist/packages/webpack5", + "typescript": "~5.9.3" + } + }, + "apps/ui": { + "extraneous": true, + "license": "MIT", + "dependencies": { + "@nativescript/core": "file:../../packages/core", + "nativescript-theme-core": "^1.0.4" + }, + "devDependencies": { + "@nativescript/android": "~9.0.0", + "@nativescript/ios": "~9.0.0", + "@nativescript/visionos": "~9.0.0", + "@nativescript/webpack": "file:../../dist/packages/webpack5", + "typescript": "~5.9.3" + } + }, + "dist/packages/vite": { + "name": "@nativescript/vite", + "version": "1.0.2", + "extraneous": true, + "dependencies": { + "@analogjs/vite-plugin-angular": "^2.0.0", + "@angular-devkit/build-angular": "^20.0.0", + "@angular/build": "^20.0.0", + "@babel/core": "^7.28.0", + "@babel/generator": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-replace": "^6.0.2", + "@vitejs/plugin-vue": "^6.0.1", + "@vitejs/plugin-vue-jsx": "^5.1.1", + "@vue/compiler-sfc": "^3.4.38", + "esbuild": "^0.25.0", + "minimist": "^1.2.8", + "react-reconciler": "^0.32.0", + "sass": ">=1.70.0 <2", + "vite": "^7.2.0", + "vite-plugin-solid": "^2.11.8", + "vite-plugin-static-copy": "^3.1.0", + "ws": "^8.18.0" + }, + "bin": { + "nativescript-vite": "bin/cli.cjs" + }, + "devDependencies": { + "@types/node": "^24.9.1", + "vitest": "^3.2.4" + } + }, + "dist/packages/webpack5": { + "name": "@nativescript/webpack", + "version": "5.0.27", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "^7.0.0", + "@pmmmwh/react-refresh-webpack-plugin": "~0.6.1", + "@vue/compiler-sfc": "^3.5.17", + "acorn": "^8.0.0", + "acorn-stage3": "^4.0.0", + "ansi-colors": "^4.1.3", + "babel-loader": "^10.0.0", + "cli-highlight": "^2.0.0", + "commander": "^14.0.0", + "copy-webpack-plugin": "^13.0.0", + "css": "^3.0.0", + "css-loader": "^7.0.0", + "dotenv-webpack": "^8.0.0", + "fork-ts-checker-webpack-plugin": "^9.0.0", + "loader-utils": "^2.0.0 || ^3.0.0", + "lodash.get": "^4.0.0", + "micromatch": "^4.0.0", + "postcss": "^8.0.0", + "postcss-import": "^16.0.0", + "postcss-loader": "^8.0.0", + "raw-loader": "^4.0.0", + "react-refresh": "~0.18.0", + "sass": "^1.0.0", + "sass-loader": "^16.0.0", + "sax": "^1.0.0", + "semver": "^7.0.0 || ^6.0.0", + "source-map": "^0.7.0", + "terser-webpack-plugin": "^5.0.0", + "ts-dedent": "^2.0.0", + "ts-loader": "^9.0.0", + "vue-loader": "^17.4.2", + "webpack": "^5.30.0 <= 5.50.0 || ^5.51.2", + "webpack-bundle-analyzer": "^4.0.0", + "webpack-chain": "^6.0.0", + "webpack-cli": "^6.0.0", + "webpack-merge": "^6.0.0", + "webpack-virtual-modules": "^0.4.0" + }, + "bin": { + "nativescript-webpack": "dist/bin/index.js" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^20.0.0", + "@angular/compiler-cli": "^20.0.0", + "@babel/helpers": ">=7.26.10", + "@types/css": "0.0.38", + "@types/jest": "29.5.4", + "@types/loader-utils": "2.0.6", + "@types/lodash.get": "4.4.9", + "@types/micromatch": "4.0.9", + "@types/sax": "1.2.7", + "@types/terser-webpack-plugin": "5.2.0", + "@types/webpack-virtual-modules": "0.4.2", + "jest": "~29.7.0", + "jest-matcher-utils": "~29.7.0", + "nativescript-vue-template-compiler": "2.9.3", + "ts-jest": "29.4.5", + "typescript": "~5.9.3" + }, + "peerDependencies": { + "nativescript-vue-template-compiler": "^2.8.1" + }, + "peerDependenciesMeta": { + "nativescript-vue-template-compiler": { + "optional": true + } + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -10522,6 +10680,10 @@ "win32" ] }, + "node_modules/@valor/nativescript-websockets": { + "version": "2.0.2", + "license": "MIT" + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -11276,45 +11438,6 @@ "@xtuc/long": "4.2.2" } }, - "node_modules/@webpack-cli/configtest": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", - "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "webpack": "4.x.x || 5.x.x", - "webpack-cli": "4.x.x" - } - }, - "node_modules/@webpack-cli/info": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", - "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "envinfo": "^7.7.3" - }, - "peerDependencies": { - "webpack-cli": "4.x.x" - } - }, - "node_modules/@webpack-cli/serve": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", - "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "webpack-cli": "4.x.x" - }, - "peerDependenciesMeta": { - "webpack-dev-server": { - "optional": true - } - } - }, "node_modules/@xml-tools/parser": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@xml-tools/parser/-/parser-1.0.11.tgz", @@ -12840,9 +12963,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001676", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001676.tgz", - "integrity": "sha512-Qz6zwGCiPghQXGJvgQAem79esjitvJ+CxSbSQkW9H/UX5hg8XM88d4lp2W+MEQ81j+Hip58Il+jGVdazk1z9cw==", + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "dev": true, "funding": [ { @@ -14557,9 +14680,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.50", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.50.tgz", - "integrity": "sha512-eMVObiUQ2LdgeO1F/ySTXsvqvxb6ZH2zPGaMYsWzRDdOddUa77tdmI0ltg+L16UpbWdhPmuF3wIQYyQq65WfZw==", + "version": "1.5.264", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.264.tgz", + "integrity": "sha512-1tEf0nLgltC3iy9wtlYDlQDc5Rg9lEKVjEmIHJ21rI9OcqkvD45K1oyNIRA4rR1z3LgJ7KeGzEBojVcV6m4qjA==", "dev": true, "license": "ISC" }, @@ -14698,19 +14821,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/envinfo": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", - "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", - "dev": true, - "license": "MIT", - "bin": { - "envinfo": "dist/cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -15553,16 +15663,6 @@ "fxparser": "src/cli/cli.js" } }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -21916,8 +22016,6 @@ }, "node_modules/nativescript-theme-core": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/nativescript-theme-core/-/nativescript-theme-core-1.0.7.tgz", - "integrity": "sha512-MJi3Lmj00dLylNwY31Zx5uC/vA5ylrODUzeQgkE9JPhmChOKVIJYu56NEEmSDjyphiQwT7f1Yr5gOw7dSFQo7A==", "license": "Apache-2.0" }, "node_modules/nativescript-typedoc-theme": { @@ -22236,9 +22334,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -27832,9 +27930,9 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "dev": true, "license": "MIT", "engines": { @@ -28797,107 +28895,25 @@ "node": ">=0.10.0" } }, - "node_modules/webpack-cli": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", - "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^1.2.0", - "@webpack-cli/info": "^1.5.0", - "@webpack-cli/serve": "^1.7.0", - "colorette": "^2.0.14", - "commander": "^7.0.0", - "cross-spawn": "^7.0.3", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^2.2.0", - "rechoir": "^0.7.0", - "webpack-merge": "^5.7.3" - }, - "bin": { - "webpack-cli": "bin/cli.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "4.x.x || 5.x.x" - }, - "peerDependenciesMeta": { - "@webpack-cli/generators": { - "optional": true - }, - "@webpack-cli/migrate": { - "optional": true - }, - "webpack-bundle-analyzer": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - } - } - }, - "node_modules/webpack-cli/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/webpack-cli/node_modules/interpret": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", - "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/webpack-cli/node_modules/rechoir": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", - "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve": "^1.9.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/webpack-merge": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", - "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", "dev": true, "license": "MIT", "dependencies": { "clone-deep": "^4.0.1", "flat": "^5.0.2", - "wildcard": "^2.0.0" + "wildcard": "^2.0.1" }, "engines": { - "node": ">=10.0.0" + "node": ">=18.0.0" } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", "engines": { @@ -29659,6 +29675,194 @@ "@types/jsonfile": "*", "@types/node": "*" } + }, + "packages/core": { + "name": "@nativescript/core", + "version": "9.0.5", + "extraneous": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/NativeScript" + }, + { + "type": "github", + "url": "https://github.com/sponsors/NativeScript" + } + ], + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@csstools/css-calc": "~2.1.4", + "@csstools/css-color-parser": "^3.0.10", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@nativescript/hook": "~3.0.4", + "acorn": "^8.15.0", + "css-tree": "^3.1.0", + "css-what": "^7.0.0", + "emoji-regex": "^10.2.1", + "source-map": "0.7.6", + "source-map-js": "^1.2.1", + "tslib": "^2.0.0" + } + }, + "packages/devtools": { + "name": "@nativescript/devtools", + "version": "0.0.0", + "extraneous": true, + "devDependencies": { + "@nativescript/core": "../core" + } + }, + "packages/types": { + "name": "@nativescript/types", + "version": "9.0.0", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@nativescript/types-android": "9.0.0", + "@nativescript/types-ios": "9.0.0" + } + }, + "packages/types-android": { + "name": "@nativescript/types-android", + "version": "9.0.0", + "extraneous": true, + "license": "Apache-2.0" + }, + "packages/types-ios": { + "name": "@nativescript/types-ios", + "version": "9.0.0", + "extraneous": true, + "license": "Apache-2.0" + }, + "packages/types-minimal": { + "name": "@nativescript/types-minimal", + "version": "9.0.0", + "extraneous": true, + "license": "Apache-2.0" + }, + "packages/ui-mobile-base": { + "name": "@nativescript/ui-mobile-base", + "version": "7.0.0", + "extraneous": true, + "license": "Apache-2.0" + }, + "packages/vite": { + "name": "@nativescript/vite", + "version": "1.0.2", + "extraneous": true, + "dependencies": { + "@analogjs/vite-plugin-angular": "^2.0.0", + "@angular-devkit/build-angular": "^20.0.0", + "@angular/build": "^20.0.0", + "@babel/core": "^7.28.0", + "@babel/generator": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@rollup/plugin-alias": "^6.0.0", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-replace": "^6.0.2", + "@vitejs/plugin-vue": "^6.0.1", + "@vitejs/plugin-vue-jsx": "^5.1.1", + "@vue/compiler-sfc": "^3.4.38", + "esbuild": "^0.25.0", + "minimist": "^1.2.8", + "react-reconciler": "^0.32.0", + "sass": ">=1.70.0 <2", + "vite": "^7.2.0", + "vite-plugin-solid": "^2.11.8", + "vite-plugin-static-copy": "^3.1.0", + "ws": "^8.18.0" + }, + "bin": { + "nativescript-vite": "bin/cli.cjs" + }, + "devDependencies": { + "@types/node": "^24.9.1", + "vitest": "^3.2.4" + } + }, + "packages/webpack5": { + "name": "@nativescript/webpack", + "version": "5.0.27", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "^7.0.0", + "@pmmmwh/react-refresh-webpack-plugin": "~0.6.1", + "@vue/compiler-sfc": "^3.5.17", + "acorn": "^8.0.0", + "acorn-stage3": "^4.0.0", + "ansi-colors": "^4.1.3", + "babel-loader": "^10.0.0", + "cli-highlight": "^2.0.0", + "commander": "^14.0.0", + "copy-webpack-plugin": "^13.0.0", + "css": "^3.0.0", + "css-loader": "^7.0.0", + "dotenv-webpack": "^8.0.0", + "fork-ts-checker-webpack-plugin": "^9.0.0", + "loader-utils": "^2.0.0 || ^3.0.0", + "lodash.get": "^4.0.0", + "micromatch": "^4.0.0", + "postcss": "^8.0.0", + "postcss-import": "^16.0.0", + "postcss-loader": "^8.0.0", + "raw-loader": "^4.0.0", + "react-refresh": "~0.18.0", + "sass": "^1.0.0", + "sass-loader": "^16.0.0", + "sax": "^1.0.0", + "semver": "^7.0.0 || ^6.0.0", + "source-map": "^0.7.0", + "terser-webpack-plugin": "^5.0.0", + "ts-dedent": "^2.0.0", + "ts-loader": "^9.0.0", + "vue-loader": "^17.4.2", + "webpack": "^5.30.0 <= 5.50.0 || ^5.51.2", + "webpack-bundle-analyzer": "^4.0.0", + "webpack-chain": "^6.0.0", + "webpack-cli": "^6.0.0", + "webpack-merge": "^6.0.0", + "webpack-virtual-modules": "^0.4.0" + }, + "bin": { + "nativescript-webpack": "dist/bin/index.js" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^20.0.0", + "@angular/compiler-cli": "^20.0.0", + "@babel/helpers": ">=7.26.10", + "@types/css": "0.0.38", + "@types/jest": "29.5.4", + "@types/loader-utils": "2.0.6", + "@types/lodash.get": "4.4.9", + "@types/micromatch": "4.0.9", + "@types/sax": "1.2.7", + "@types/terser-webpack-plugin": "5.2.0", + "@types/webpack-virtual-modules": "0.4.2", + "jest": "~29.7.0", + "jest-matcher-utils": "~29.7.0", + "nativescript-vue-template-compiler": "2.9.3", + "ts-jest": "29.4.5", + "typescript": "~5.9.3" + }, + "peerDependencies": { + "nativescript-vue-template-compiler": "^2.8.1" + }, + "peerDependenciesMeta": { + "nativescript-vue-template-compiler": { + "optional": true + } + } + }, + "packages/winter-tc": { + "name": "@nativescript/winter-tc", + "version": "0.0.1", + "extraneous": true, + "license": "Apache-2.0" } } } diff --git a/package.json b/package.json index 245d13f5fb..851bb4e6f2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "url": "https://github.com/NativeScript/NativeScript.git" }, "dependencies": { + "@valor/nativescript-websockets": "^2.0.2", "nativescript-theme-core": "^1.0.4" }, "devDependencies": { @@ -109,11 +110,9 @@ "vite-plugin-static-copy": "^3.1.4", "vitest": "^3.2.4", "vue-loader": "^15.0.0 <= 15.9.8", - "webpack": "^5.30.0 <= 5.50.0 || ^5.51.2", - "webpack-bundle-analyzer": "^4.0.0", "webpack-chain": "^6.0.0", - "webpack-cli": "^4.0.0", - "webpack-merge": "^5.0.0", + "webpack-bundle-analyzer": "^4.0.0", + "webpack-merge": "^6.0.0", "webpack-virtual-modules": "^0.4.0", "zx": "^8.3.0" }, diff --git a/packages/vite/bin/cli.cjs b/packages/vite/bin/cli.cjs old mode 100644 new mode 100755 diff --git a/packages/vite/hmr/shared/vendor/manifest.ts b/packages/vite/hmr/shared/vendor/manifest.ts index 364b232024..e4a1865fa0 100644 --- a/packages/vite/hmr/shared/vendor/manifest.ts +++ b/packages/vite/hmr/shared/vendor/manifest.ts @@ -26,6 +26,12 @@ interface VendorBundleResult { entries: string[]; } +// Internal representation of resolved vendor inputs, including any metadata we +// need during esbuild bundling +interface CollectedVendorModules { + entries: string[]; +} + interface VendorManifestPluginOptions { projectRoot: string; platform: string; @@ -98,6 +104,7 @@ const ALWAYS_EXCLUDE = new Set([ 'vue-tsc', 'ws', '@types/node', + 'nativescript-theme-core', ]); const INDEX_ALIAS_SUFFIXES = ['/index', '/index.js', '/index.android.js', '/index.ios.js', '/index.visionos.js']; @@ -266,8 +273,8 @@ export default vendorManifest; async function generateVendorBundle(options: GenerateVendorOptions): Promise { const { projectRoot, platform, mode, flavor } = options; - const entries = collectVendorModules(projectRoot, platform, flavor); - const entryCode = createVendorEntry(entries); + const collected = collectVendorModules(projectRoot, platform, flavor); + const entryCode = createVendorEntry(collected.entries); const plugins: esbuild.Plugin[] = [ // Resolve virtual modules and Angular shims used by the vendor entry. @@ -320,16 +327,16 @@ async function generateVendorBundle(options: GenerateVendorOptions): Promise Date: Sat, 13 Dec 2025 00:04:59 -0800 Subject: [PATCH 05/13] fix(vite): global flags for hmr --- packages/vite/helpers/main-entry.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/vite/helpers/main-entry.ts b/packages/vite/helpers/main-entry.ts index 583d98fca9..4ffb2fa9a5 100644 --- a/packages/vite/helpers/main-entry.ts +++ b/packages/vite/helpers/main-entry.ts @@ -67,6 +67,14 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos' } if (opts.hmrActive) { + // Seed platform globals on the primary bundle realm using the same + // CLI-derived platform that drives global-defines.ts. This ensures + // HMR-delivered HTTP ESM modules can reliably read platform flags + imports += `globalThis.__DEV__ = ${opts.isDevMode ? 'true' : 'false'};\n`; + imports += `globalThis.__ANDROID__ = ${opts.platform === 'android' ? 'true' : 'false'};\n`; + imports += `globalThis.__IOS__ = ${opts.platform === 'ios' ? 'true' : 'false'};\n`; + imports += `globalThis.__VISIONOS__ = ${opts.platform === 'visionos' ? 'true' : 'false'};\n`; + imports += `globalThis.__APPLE__ = ${opts.platform === 'ios' || opts.platform === 'visionos' ? 'true' : 'false'};\n`; // ---- Vendor manifest bootstrap ---- // Use single self-contained vendor module to avoid extra imports affecting chunking imports += "import vendorManifest, { __nsVendorModuleMap } from '@nativescript/vendor';\n"; From 414ce4398bc7dec8cdf4853430a69499b45c8746 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 13 Dec 2025 00:12:56 -0800 Subject: [PATCH 06/13] chore: log cleanup --- packages/vite/hmr/client/index.ts | 473 +++++------------- packages/vite/hmr/client/utils.ts | 189 +------ .../vite/hmr/frameworks/vue/client/index.ts | 5 +- packages/vite/hmr/server/websocket.ts | 220 ++------ 4 files changed, 196 insertions(+), 691 deletions(-) diff --git a/packages/vite/hmr/client/index.ts b/packages/vite/hmr/client/index.ts index 888d51b228..fced6c15fa 100644 --- a/packages/vite/hmr/client/index.ts +++ b/packages/vite/hmr/client/index.ts @@ -6,7 +6,7 @@ * The HMR client is evaluated via HTTP ESM on device; static imports would create secondary instances. */ -import { setHMRWsUrl, getHMRWsUrl, pendingModuleFetches, deriveHttpOrigin, setHttpOriginForVite, moduleFetchCache, requestModuleFromServer, getHttpOriginForVite, normalizeSpec, hmrMetrics, graph, setGraphVersion, getGraphVersion, getCurrentApp, getRootFrame, setCurrentApp, setRootFrame, getCore, attachDiagnosticsToFrame, logUiSnapshot } from './utils.js'; +import { setHMRWsUrl, getHMRWsUrl, pendingModuleFetches, deriveHttpOrigin, setHttpOriginForVite, moduleFetchCache, requestModuleFromServer, getHttpOriginForVite, normalizeSpec, hmrMetrics, graph, setGraphVersion, getGraphVersion, getCurrentApp, getRootFrame, setCurrentApp, setRootFrame, getCore } from './utils.js'; import { handleCssUpdates } from './css-handler.js'; // satisfied by define replacement @@ -22,13 +22,6 @@ const APP_MAIN_ENTRY_SPEC = `${APP_VIRTUAL_WITH_SLASH}app.ts`; // Policy: by default, let the app's own main entry mount initially; HMR client handles updates/remounts only. // Flip this to true via global __NS_HMR_ALLOW_INITIAL_MOUNT__ if you need the client to perform the first mount. const ALLOW_INITIAL_MOUNT: boolean = !!globalThis.__NS_HMR_ALLOW_INITIAL_MOUNT__; -// When verbose mode is enabled, also enable runtime nav diagnostics so /ns/rt logs are visible -try { - if (VERBOSE) { - globalThis.__NS_DEV_LOGS__ = true; - globalThis.__NS_VERBOSE_RT_NAV__ = true; - } -} catch {} // Ensure core aliases are present on globalThis early so that /ns/rt exports resolve to functions // before any SFCs are evaluated during HTTP-only dev boot. @@ -48,45 +41,11 @@ function ensureCoreAliasesOnGlobalThis() { try { if (P && !g.Page) g.Page = P; } catch {} - // Optional diagnostics: compare with vendor realm if available - if (VERBOSE) { - let vF: any, vA: any, vP: any; - try { - const reg: Map | undefined = g.__nsVendorRegistry; - const vmod = reg?.get ? reg.get('@nativescript/core') : undefined; - const vns = (vmod && (vmod.default || vmod)) || vmod; - vF = vns?.Frame; - vA = vns?.Application; - vP = vns?.Page; - } catch {} - try { - console.log('[hmr-client] core alias status', { - globalHas: { Frame: !!F, Application: !!A, Page: !!P }, - globalMethods: { - FrameTopmost: typeof F?.topmost === 'function', - AppResetRoot: typeof A?.resetRootView === 'function', - }, - sameRef: { - Frame: F && vF ? F === vF : undefined, - Application: A && vA ? A === vA : undefined, - Page: P && vP ? P === vP : undefined, - }, - ctorNames: { - Frame: F?.name || F?.constructor?.name, - Application: A?.name || A?.constructor?.name, - Page: P?.name || P?.constructor?.name, - }, - }); - } catch {} - } } catch {} } // Apply once on module evaluation ensureCoreAliasesOnGlobalThis(); -// Install low-level diagnostics for navigation and root replacement to trace duplicates and state -installDeepDiagnostics(); - /** * Flavor hooks */ @@ -94,190 +53,13 @@ import { installNsVueDevShims, ensureBackWrapperInstalled, getRootForVue, loadSf import { handleAngularHotUpdateMessage, installAngularHmrClientHooks } from '../frameworks/angular/client/index.js'; switch (__NS_TARGET_FLAVOR__) { case 'vue': - if (VERBOSE) { - console.log('[hmr-client] installing nativescript-vue dev shims'); - } installNsVueDevShims(); break; case 'angular': - if (VERBOSE) { - try { - console.log('[hmr-client] Initializing Angular HMR shims'); - } catch {} - } installAngularHmrClientHooks(); break; } -// Global frame diagnostics: instrument Frame.navigate and Frame.topmost to detect -// navigation against non-authoritative frames across the app (helps gray-screen cases) -try { - const g: any = globalThis as any; - const F: any = getCore('Frame') || g.Frame; - if (F && F.prototype && !g.__NS_DEV_GLOBAL_FRAME_PATCHED__) { - const tag = (fr: any) => { - try { - if (!fr) return; - if (!fr.__ns_tag) fr.__ns_tag = Math.random().toString(36).slice(2); - } catch {} - }; - const proto = F.prototype; - const origNav = proto.navigate; - if (typeof origNav === 'function') { - proto.navigate = function __ns_diag_nav(entry: any) { - try { - tag(this); - console.log('[diag][global][frame.navigate]', { - tag: (this as any).__ns_tag, - type: this?.constructor?.name, - hasCreate: !!entry?.create, - clearHistory: !!entry?.clearHistory, - animated: !!entry?.animated, - }); - } catch {} - return origNav.apply(this, arguments as any); - } as any; - } - const origTop = typeof F.topmost === 'function' ? F.topmost.bind(F) : null; - if (origTop) { - F.topmost = function __ns_diag_topmost() { - const fr = origTop(); - try { - tag(fr); - console.log('[diag][global][Frame.topmost]', { - tag: (fr as any)?.__ns_tag, - type: (fr as any)?.constructor?.name, - }); - } catch {} - return fr; - } as any; - } - try { - g.__NS_DEV_GLOBAL_FRAME_PATCHED__ = true; - } catch {} - } -} catch {} - -// --- Diagnostics helpers ---------------------------------------------------- -function summarizeNavEntry(entry: any) { - try { - if (!entry) return { kind: 'empty' }; - if (typeof entry === 'string') return { kind: 'string', moduleName: entry }; - const hasCreate = typeof (entry as any).create === 'function'; - const moduleName = (entry as any).moduleName; - const clearHistory = !!(entry as any).clearHistory; - const animated = (entry as any).animated; - const backstackVisible = (entry as any).backstackVisible; - const contextKeys = Object.keys((entry as any).context || {}); - return { - kind: 'entry', - hasCreate, - moduleName, - clearHistory, - animated, - backstackVisible, - contextKeys, - }; - } catch { - return { kind: 'unknown' }; - } -} - -function classifyResetArg(arg: any) { - try { - const ctorName = String(arg?.constructor?.name || '').replace(/^_+/, ''); - const keys = Object.keys(arg || {}); - const hasCreate = typeof arg?.create === 'function'; - const hasModuleName = typeof arg?.moduleName === 'string'; - const isFrameLike = !!arg && (ctorName === 'Frame' || /^Frame(\$\d+)?$/.test(ctorName) || (typeof arg?.navigate === 'function' && typeof arg?.addChild === 'function')); - const isPageLike = !!arg && (ctorName === 'Page' || /^Page(\$\d+)?$/.test(ctorName) || (typeof arg?.content !== 'undefined' && typeof arg?.addChild === 'function')); - return { - ctorName, - keys, - hasCreate, - hasModuleName, - isFrameLike, - isPageLike, - }; - } catch { - return { ctorName: 'unknown' }; - } -} - -function installDeepDiagnostics() { - if (!VERBOSE) return; - const g: any = globalThis as any; - try { - // Patch Frame.navigate to log calls and a short stack - const F = getCore('Frame') || g.Frame; - if (F?.prototype && !(F.prototype as any).__ns_diag_nav__) { - const orig = F.prototype.navigate; - if (typeof orig === 'function') { - (F.prototype as any).__ns_diag_nav__ = true; - // Simple duplicate navigation suppression in dev: if the same target is navigated twice within a short window, ignore the 2nd. - F.prototype.navigate = function (...args: any[]) { - try { - const entry = args[0]; - const summary = summarizeNavEntry(entry); - const stack = (new Error().stack || '').split('\n').slice(2, 8).join('\n'); - console.log('[diag][Frame.navigate]', { - frameCtor: this?.constructor?.name, - summary, - stack, - }); - try { - const gAny: any = globalThis as any; - const key = JSON.stringify({ - k: 'nav', - m: summary.moduleName || '', - c: !!summary.hasCreate, - ch: !!summary.clearHistory, - a: !!summary.animated, - }); - const now = Date.now(); - const last = gAny.__NS_DIAG_LAST_NAV__; - if (last && last.key === key && now - last.t < 300) { - console.warn('[diag][Frame.navigate] duplicate nav suppressed (dev)', { withinMs: now - last.t, key }); - return; // suppress duplicate - } - gAny.__NS_DIAG_LAST_NAV__ = { key, t: now }; - } catch {} - } catch {} - return orig.apply(this, args as any); - } as any; - } - } - } catch {} - try { - // Wrap Application.resetRootView to log argument classification and stack - const App = getCore('Application') || g.Application; - const proto = App && Object.getPrototypeOf(App); - const orig = (App && App.resetRootView) || (proto && proto.resetRootView); - if (typeof orig === 'function' && !(g as any).__NS_DIAG_RESET_WRAPPED__) { - const wrapped = function __ns_diag_resetRootView(this: any, entry: any) { - try { - const classification = classifyResetArg(entry); - const stack = (new Error().stack || '').split('\n').slice(2, 8).join('\n'); - console.log('[diag][Application.resetRootView]', { - classification, - stack, - }); - } catch {} - return orig.call(this, entry); - } as any; - try { - App.resetRootView = wrapped; - } catch {} - try { - if (proto && typeof proto === 'object') (proto as any).resetRootView = wrapped; - } catch {} - try { - (g as any).__NS_DIAG_RESET_WRAPPED__ = true; - } catch {} - } - } catch {} -} - // Track whether we've mounted an initial app root yet in HTTP-only boot let initialMounted = !!(globalThis as any).__NS_HMR_BOOT_COMPLETE__; // Prevent duplicate initial-mount scheduling across rapid full-graph broadcasts and re-evaluations @@ -287,6 +69,7 @@ let tsModuleSet: Set | null = null; let tsMainId: string | null = null; const changedQueue: any[] = []; let processingQueue = false; +let processingPromise: Promise | null = null; // Detect whether the early placeholder root is still active on screen function isPlaceholderActive(): boolean { @@ -759,17 +542,7 @@ function __nsNavigateUsingApp(comp: any, opts: any = {}) { try { (fr as any).navigate(navEntry); } catch {} - try { - attachDiagnosticsToFrame(fr); - } catch {} setRootFrame(fr); - try { - (getRootFrame() as any).__ns_tag ||= Math.random().toString(36).slice(2); - console.log('[diag][root] ROOT_FRAME set (app-nav)', { - tag: getRootFrame()?.__ns_tag, - type: getRootFrame()?.constructor?.name, - }); - } catch {} return fr; }, } as any); @@ -777,24 +550,8 @@ function __nsNavigateUsingApp(comp: any, opts: any = {}) { } throw new Error('Application.resetRootView unavailable'); } - try { - attachDiagnosticsToFrame(frame); - } catch {} const navEntry = { create: () => buildTarget(), ...(opts || {}) } as any; - try { - const summary = summarizeNavEntry(navEntry); - if (VERBOSE) console.log('[app-nav] navigate entry', summary); - } catch {} (frame as any).navigate(navEntry); - try { - const top2 = (g.Frame && g.Frame.topmost && g.Frame.topmost()) || null; - const ctor2 = top2 && top2.constructor && top2.constructor.name; - if (VERBOSE) - console.log('[app-nav] after navigate', { - topCtor: ctor2, - hasTop: !!top2, - }); - } catch {} return undefined; } @@ -803,7 +560,7 @@ try { (globalThis as any).__nsNavigateUsingApp = __nsNavigateUsingApp; } catch {} -async function processQueue() { +async function processQueue(): Promise { if (!(globalThis as any).__NS_HMR_BOOT_COMPLETE__) { if (VERBOSE) console.log('[hmr][gate] deferring HMR eval until boot complete'); setTimeout(() => { @@ -811,64 +568,68 @@ async function processQueue() { processQueue(); } catch {} }, 150); - return; + return Promise.resolve(); } - if (processingQueue) return; + if (processingQueue) return processingPromise || Promise.resolve(); processingQueue = true; - try { - // Simple deterministic drain of the queue. We currently focus on TS flavor - // by re-importing changed modules (to refresh their HTTP ESM copies) and - // then performing a root reset so the UI reflects the new code. - const seen = new Set(); - const drained: string[] = []; - while (changedQueue.length) { - const id = changedQueue.shift(); - if (!id || typeof id !== 'string') continue; - if (seen.has(id)) continue; - seen.add(id); - drained.push(id); - } - if (!drained.length) return; - if (VERBOSE) console.log('[hmr][queue] processing changed ids', drained); - // Evaluate changed modules best-effort; failures shouldn't completely break HMR. - for (const id of drained) { - try { - const spec = normalizeSpec(id); - const url = await requestModuleFromServer(spec); - if (!url) continue; - if (VERBOSE) console.log('[hmr][queue] re-import', { id, spec, url }); - await import(/* @vite-ignore */ url); - } catch (e) { - if (VERBOSE) console.warn('[hmr][queue] re-import failed for', id, e); + processingPromise = (async () => { + try { + // Simple deterministic drain of the queue. We currently focus on TS flavor + // by re-importing changed modules (to refresh their HTTP ESM copies) and + // then performing a root reset so the UI reflects the new code. + const seen = new Set(); + const drained: string[] = []; + while (changedQueue.length) { + const id = changedQueue.shift(); + if (!id || typeof id !== 'string') continue; + if (seen.has(id)) continue; + seen.add(id); + drained.push(id); } - } - // After evaluating the batch, perform flavor-specific UI refresh. - switch (__NS_TARGET_FLAVOR__) { - case 'vue': - // Vue SFCs are handled via the registry update path; nothing to do here. - break; - case 'typescript': { - // For TS apps, always reset back to the conventional app root. - // This preserves the shell (Frame, ActionBar, etc.) that the app's - // own bootstrapping wires up via `Application.run`. + if (!drained.length) return; + if (VERBOSE) console.log('[hmr][queue] processing changed ids', drained); + // Evaluate changed modules best-effort; failures shouldn't completely break HMR. + for (const id of drained) { try { - const g: any = globalThis as any; - const App = getCore('Application') || g.Application; - if (!App || typeof App.resetRootView !== 'function') { - if (VERBOSE) console.warn('[hmr][queue] TS flavor: Application.resetRootView unavailable; skipping UI refresh'); - break; - } - if (VERBOSE) console.log('[hmr][queue] TS flavor: resetRootView(app-root) after changes'); - App.resetRootView({ moduleName: 'app-root' } as any); + const spec = normalizeSpec(id); + const url = await requestModuleFromServer(spec); + if (!url) continue; + if (VERBOSE) console.log('[hmr][queue] re-import', { id, spec, url }); + const mod: any = await import(/* @vite-ignore */ url); } catch (e) { - console.warn('[hmr][queue] TS flavor: resetRootView(app-root) failed', e); + if (VERBOSE) console.warn('[hmr][queue] re-import failed for', id, e); } - break; } + // After evaluating the batch, perform flavor-specific UI refresh. + switch (__NS_TARGET_FLAVOR__) { + case 'vue': + // Vue SFCs are handled via the registry update path; nothing to do here. + break; + case 'typescript': { + // For TS apps, always reset back to the conventional app root. + // This preserves the shell (Frame, ActionBar, etc.) that the app's + // own bootstrapping wires up via `Application.run`. + try { + const g: any = globalThis as any; + const App = getCore('Application') || g.Application; + if (!App || typeof App.resetRootView !== 'function') { + if (VERBOSE) console.warn('[hmr][queue] TS flavor: Application.resetRootView unavailable; skipping UI refresh'); + break; + } + if (VERBOSE) console.log('[hmr][queue] TS flavor: resetRootView(app-root) after changes'); + App.resetRootView({ moduleName: 'app-root' } as any); + } catch (e) { + console.warn('[hmr][queue] TS flavor: resetRootView(app-root) failed', e); + } + break; + } + } + } finally { + processingQueue = false; + processingPromise = null; } - } finally { - processingQueue = false; - } + })(); + return processingPromise; } let hmrSocket: WebSocket | null = null; // Track server-announced batches for each version so we can import in-order client-side @@ -1017,11 +778,65 @@ async function handleHmrMessage(ev: any) { } catch { return; } - if (VERBOSE) console.log('[hmr-client] msg', msg); + + // Notify optional app-level hook after an HMR batch is applied. + function notifyAppHmrUpdate(kind: 'full-graph' | 'delta', changedIds: string[] | undefined) { + try { + const hook = globalThis.__NS_HMR_ON_UPDATE__; + if (typeof hook === 'function') { + hook({ type: kind, version: getGraphVersion(), changedIds: changedIds || [], raw: msg }); + } + } catch {} + } + if (msg) { if (msg.type === 'ns:hmr-full-graph') { + // Bump a monotonic nonce so HTTP ESM imports can always be cache-busted per update. + try { + const g: any = globalThis as any; + g.__NS_HMR_IMPORT_NONCE__ = (typeof g.__NS_HMR_IMPORT_NONCE__ === 'number' ? g.__NS_HMR_IMPORT_NONCE__ : 0) + 1; + } catch {} + // Capture previous graph snapshot so we can infer which modules changed. + const prevGraph = new Map(graph); setGraphVersion(Number(msg.version || getGraphVersion() || 0)); applyFullGraph(msg); + // In some cases (e.g. server chooses full-graph resync / page reload), we won't + // receive a delta queue to re-import changed TS modules. Without re-import, + // HTTP ESM caching means module bodies (and side effects) won't re-run. + try { + const inferredChanged: string[] = []; + try { + for (const [id, next] of graph.entries()) { + const prev = prevGraph.get(id); + if (!prev || prev.hash !== next.hash) inferredChanged.push(id); + } + // Removed modules are also "changed" but we don't import them. + } catch {} + // Best-effort: only re-import real source modules (avoid .vue registry pipeline and virtual ids) + const toReimport = inferredChanged.filter((id) => { + if (!id || typeof id !== 'string') return false; + if (/^\0|^\/\0/.test(id)) return false; + if (/plugin-vue:export-helper/.test(id)) return false; + if (/\.vue$/i.test(id)) return false; + if (id.endsWith(APP_MAIN_ENTRY_SPEC)) return false; + return true; + }); + if (toReimport.length && VERBOSE) console.log('[hmr][full-graph] inferred changed modules; re-importing', toReimport); + for (const id of toReimport) { + try { + const spec = normalizeSpec(id); + const url = await requestModuleFromServer(spec); + if (!url) continue; + if (VERBOSE) console.log('[hmr][full-graph] re-import', { id, spec, url }); + await import(/* @vite-ignore */ url); + } catch (e) { + if (VERBOSE) console.warn('[hmr][full-graph] re-import failed for', id, e); + } + } + } catch {} + + const fullIds = Array.isArray(msg.modules) ? msg.modules.map((m: any) => m?.id).filter(Boolean) : []; + notifyAppHmrUpdate('full-graph', fullIds); return; } if (msg.type === 'ns:ts-module-registry') { @@ -1048,12 +863,24 @@ async function handleHmrMessage(ev: any) { return; } if (msg.type === 'ns:hmr-delta') { - setGraphVersion(Number(msg.newVersion || getGraphVersion() || 0)); + // Bump a monotonic nonce so HTTP ESM imports can always be cache-busted per update. + try { + const g: any = globalThis as any; + g.__NS_HMR_IMPORT_NONCE__ = (typeof g.__NS_HMR_IMPORT_NONCE__ === 'number' ? g.__NS_HMR_IMPORT_NONCE__ : 0) + 1; + } catch {} try { const ids = Array.isArray(msg.changed) ? msg.changed.map((c: any) => c?.id).filter(Boolean) : []; if (ids.length) txnClientBatches.set(getGraphVersion(), ids); } catch {} applyDelta(msg); + // Ensure queued module re-imports complete before notifying app hooks. + // Otherwise app-level handlers can run against stale module bodies due to HTTP ESM caching. + try { + await processQueue(); + } catch {} + + const deltaIds = Array.isArray(msg.changed) ? msg.changed.map((c: any) => c?.id).filter(Boolean) : []; + notifyAppHmrUpdate('delta', deltaIds); return; } else if (handleAngularHotUpdateMessage(msg, { getCore, verbose: VERBOSE })) { return; @@ -1156,22 +983,6 @@ async function performResetRoot(newComponent: any): Promise { try { ensureCoreAliasesOnGlobalThis(); } catch {} - try { - if (VERBOSE) logUiSnapshot('pre-performResetRoot'); - } catch {} - if (VERBOSE) { - try { - const g: any = globalThis as any; - const vF = getCore('Frame'); - console.log('[hmr-client] alias check before remount', { - globalFrameHasTopmost: typeof g?.Frame?.topmost === 'function', - vendorFrameHasTopmost: typeof vF?.topmost === 'function', - sameFrameRef: vF === g?.Frame, - appHasReset: typeof g?.Application?.resetRootView === 'function', - pageIsCtor: typeof g?.Page === 'function', - }); - } catch {} - } if (VERBOSE) { console.log('[hmr-client] Single-path: replace current root Page'); console.log('[hmr-client] Component details:', { @@ -1361,10 +1172,6 @@ async function performResetRoot(newComponent: any): Promise { const isAuthoritativeFrame = !!existingAppFrame && existingAppFrame !== placeholderFrame; if (!hadPlaceholder && !isFrameRoot && isAuthoritativeFrame && typeof (existingAppFrame as any).navigate === 'function') { try { - if (VERBOSE) console.log('[hmr-client] navigating authoritative app Frame to new Page (no placeholder, smooth swap)'); - try { - attachDiagnosticsToFrame(existingAppFrame); - } catch {} const navEntry = { create: () => preparedRoot, clearHistory: true, @@ -1440,57 +1247,27 @@ async function performResetRoot(newComponent: any): Promise { } as any; try { (fr as any).navigate(navEntry); - if (VERBOSE) console.log('[hmr-client] resetRootView:create navigated Frame'); } catch (e) { console.warn('[hmr-client] resetRootView:create navigate failed', e); } - try { - attachDiagnosticsToFrame(fr); - } catch {} setRootFrame(fr); - try { - (getRootFrame() as any).__ns_tag ||= Math.random().toString(36).slice(2); - console.log('[diag][root] ROOT_FRAME set (new)', { - tag: getRootFrame()?.__ns_tag, - type: getRootFrame()?.constructor?.name, - }); - } catch {} return fr; } else { const fr = preparedRoot; - if (VERBOSE) console.log('[hmr-client] resetRootView:create using provided Frame', { type: fr?.constructor?.name }); - try { - attachDiagnosticsToFrame(fr); - } catch {} setRootFrame(fr); - try { - (getRootFrame() as any).__ns_tag ||= Math.random().toString(36).slice(2); - console.log('[diag][root] ROOT_FRAME set (provided)', { - tag: getRootFrame()?.__ns_tag, - type: getRootFrame()?.constructor?.name, - }); - } catch {} return fr; } }, } as any; - if (VERBOSE) console.log('[hmr-client] invoking Application.resetRootView with entry (always)', { isFrameRoot, hadPlaceholder, isIOS }); // Always use an entry with a create() function to avoid cross‑realm instanceof checks on Android. App2.resetRootView(entry as any); // After authoritative reset, it's safe to detach the early placeholder launch handler try { const restore = (globalThis as any).__NS_DEV_RESTORE_PLACEHOLDER__; if (typeof restore === 'function') { - if (VERBOSE) console.log('[hmr-client] restoring: detach early placeholder launch handler'); restore(); } } catch {} - if (VERBOSE) { - logUiSnapshot('post-resetRootView'); - console.log('[hmr-client] performResetRoot completed', { - elapsedMs: Date.now() - tStart, - }); - } return true; } catch (e) { console.warn('[hmr-client] resetRootView failed', e); diff --git a/packages/vite/hmr/client/utils.ts b/packages/vite/hmr/client/utils.ts index 13bf60f57f..061ccd7b4e 100644 --- a/packages/vite/hmr/client/utils.ts +++ b/packages/vite/hmr/client/utils.ts @@ -153,9 +153,6 @@ export function deriveHttpOrigin(wsUrl: string | undefined) { const url = new URL(wsUrl || 'ws://localhost:5173/ns-hmr'); const http = url.protocol === 'wss:' ? 'https:' : 'http:'; const origin = `${http}//${url.host}`; - if (!/^https?:\/\/[\w\-.:\[\]]+$/.test(origin)) { - console.warn('[hmr-client][origin] invariant failed for', wsUrl, '→', origin); - } return origin; } catch { return 'http://localhost:5173'; @@ -166,7 +163,6 @@ export async function requestModuleFromServer(spec: string): Promise { const isSfcArtifact = (s: string) => /(?:^|\/)sfc-[a-f0-9]{8}\.mjs$/i.test(s) || /\/_ns_hmr\/src\/sfc\//.test(s) || /__NSDOC__\/_ns_hmr\/src\/sfc\//.test(s); // Ignore Vite/virtual helper or empty specs if (/^\/?\0/.test(spec) || /plugin-vue:export-helper/.test(spec)) { - if (__NS_ENV_VERBOSE__) console.log('[hmr-fetch] skipping virtual helper', spec); return Promise.reject(new Error('virtual-helper-skip')); } // Short-circuit device artifact paths (SFC compiled files) – never ask the server for these @@ -181,16 +177,32 @@ export async function requestModuleFromServer(spec: string): Promise { // Let the server send the JSON-wrapped ESM code; we will write it as-is. // We still go through the normal request flow below, but short-circuit index heuristics. } - // Cache hit: return immediately - if (moduleFetchCache.has(spec)) { - if (__NS_ENV_VERBOSE__) console.log('[hmr-fetch] cache hit', spec); - return Promise.resolve(moduleFetchCache.get(spec)!); - } - // Construct HTTP ESM URL for this spec and cache it + // Construct HTTP ESM URL for this spec. + // IMPORTANT: cache-bust via PATH (not query) because the iOS HTTP ESM cache key + // canonicalization strips query params. const origin = httpOriginForVite || deriveHttpOrigin(hmrWsUrl); if (!origin) return Promise.reject(new Error('no-http-origin')); - const url = origin + '/ns/m' + (spec.startsWith('/') ? spec : '/' + spec); - if (__NS_ENV_VERBOSE__) console.log('[hmr-fetch] resolved', spec, '→', url); + const basePath = '/ns/m' + (spec.startsWith('/') ? spec : '/' + spec); + const baseUrl = origin + basePath; + let url = baseUrl; + try { + // Use version+hash to avoid ESM cache returning the old module. + const v = typeof graphVersion === 'number' ? graphVersion : 0; + const h = graph.get(spec)?.hash || ''; + const g: any = globalThis as any; + const n = typeof g?.__NS_HMR_IMPORT_NONCE__ === 'number' ? g.__NS_HMR_IMPORT_NONCE__ : 0; + // Only add params when we have at least one signal. + if (v || h || n) { + // Prefer nonce when present to guarantee changes apply even if server version/hash are stable. + const tag = n ? `${n}-${v}${h ? `-${h}` : ''}` : h ? `${v}-${h}` : String(v); + // /ns/m/__ns_hmr__// + url = origin + '/ns/m/__ns_hmr__/' + encodeURIComponent(tag) + basePath.slice('/ns/m'.length); + } + } catch {} + const prev = moduleFetchCache.get(spec); + if (prev === url) { + return Promise.resolve(url); + } moduleFetchCache.set(spec, url); return Promise.resolve(url); } @@ -203,94 +215,15 @@ export async function safeDynImport(spec: string): Promise { if (!finalSpec || finalSpec === '@') { finalSpec = (origin ? origin : '') + '/ns/m/__invalid_at__.mjs'; } - if (__NS_ENV_VERBOSE__) { - try { - console.log('[hmr-client][dyn-import]', 'spec=', spec, 'final=', finalSpec); - } catch {} - } // Use native dynamic import // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - dynamic import expression return await import(finalSpec); } catch (e) { - if (__NS_ENV_VERBOSE__) { - console.warn('[hmr-client][dyn-import][error]', spec, e); - } - // Best-effort diagnostics: fetch sanitized and raw sources and print a snippet around the failing frame - try { - await dumpDynImportDiagnostics(spec, e as any); - } catch {} throw e; } } -// Extract a (url, line, column) triple from a V8 stack string -function parseStackFrame(err: any): { - url?: string; - line?: number; - column?: number; -} { - try { - const stack: string = (err && (err.stack || err.message)) || ''; - // Match patterns like: at (http://host/path.js:123:45) OR http://host/path.js:123:45 - const re = /(https?:[^\s)]+):(\d+):(\d+)/; - const m = stack.match(re); - if (m) { - return { - url: m[1], - line: Number(m[2] || '0'), - column: Number(m[3] || '0'), - }; - } - } catch {} - return {}; -} - -async function dumpDynImportDiagnostics(spec: string, err: any) { - try { - const origin = httpOriginForVite || deriveHttpOrigin(hmrWsUrl) || ''; - const target = spec || ''; - const { url: uFromStack, line, column } = parseStackFrame(err); - const url = uFromStack || target; - if (!/^https?:\/\//.test(url)) return; - const addParam = (u: string, k: string, v = '1') => u + (u.includes('?') ? '&' : '?') + `${k}=${v}`; - const urlsToTry = [url]; - if (/(\/ns\/(asm|sfc))/.test(url)) urlsToTry.push(addParam(url, 'raw')); - const results: Array<{ which: string; text?: string; hash?: string }> = []; - for (const u of urlsToTry) { - try { - const res = await fetch(u as any, { method: 'GET' as any }); - const text = await res.text(); - const hash = res.headers?.get?.('X-NS-Source-Hash') || undefined; - results.push({ which: u === url ? 'sanitized' : 'raw', text, hash }); - } catch {} - } - const locInfo = line && line > 0 ? ` at ${url}:${line}:${column || 0}` : ''; - try { - console.warn('[hmr-client][dyn-import][diagnostics]', `error${locInfo}`); - } catch {} - for (const r of results) { - if (!r.text) continue; - const lines = r.text.split('\n'); - let ctx = ''; - if (line && line > 0) { - const start = Math.max(1, line - 3), - end = Math.min(lines.length, line + 3); - for (let i = start; i <= end; i++) { - const mark = i === line ? '>' : ' '; - const ln = String(i).padStart(4, ' '); - ctx += `${mark}${ln}: ${lines[i - 1]}\n`; - } - } else { - ctx = lines.slice(0, 20).join('\n'); - } - try { - console.warn(`[hmr-client][dyn-import][${r.which}]`, r.hash ? `(hash ${r.hash})` : '', '\n' + ctx); - } catch {} - } - } catch {} -} - // Normalize import specifiers for HTTP-only ESM runtime export function normalizeSpec(raw: string): string { if (typeof raw !== 'string') return raw as any; @@ -321,7 +254,6 @@ export function normalizeSpec(raw: string): string { } if (spec === '@') { // Map anomalous '@' sentinel to a safe stub module path. - if (__NS_ENV_VERBOSE__) console.warn('[hmr-normalize] mapping anomalous "@" to stub module'); try { hmrMetrics.invalidAtSpec = (hmrMetrics.invalidAtSpec || 0) + 1; } catch {} @@ -329,76 +261,3 @@ export function normalizeSpec(raw: string): string { } return spec; } - -export function attachDiagnosticsToFrame(frame: any) { - if (!__NS_ENV_VERBOSE__ || !frame) return; - try { - if ((frame as any).__ns_diag_attached__) return; - (frame as any).__ns_diag_attached__ = true; - const safeOn = (v: any, evt: string, cb: Function) => { - try { - v?.on?.(evt as any, cb as any); - } catch {} - }; - const logEvt = (name: string) => (args: any) => { - try { - const page = (args && (args.object?.currentPage || args.object?._currentEntry?.resolvedPage)) || args?.object || null; - const pageCtor = String(page?.constructor?.name || '').replace(/^_+/, ''); - const tag = (page as any)?.__ns_hmr_tag || (page as any)?.__ns_diag_tag; - console.log('[diag][frame]', name, { - frameCtor: frame?.constructor?.name, - pageCtor, - tag, - backstackDepth: (frame as any)?._backStack?.length || 0, - }); - } catch {} - }; - safeOn(frame, 'navigatingTo', logEvt('navigatingTo')); - safeOn(frame, 'navigatedTo', logEvt('navigatedTo')); - safeOn(frame, 'navigatingFrom', logEvt('navigatingFrom')); - safeOn(frame, 'navigatedFrom', logEvt('navigatedFrom')); - safeOn(frame, 'loaded', () => { - try { - console.log('[diag][frame] loaded', { ctor: frame?.constructor?.name }); - } catch {} - }); - safeOn(frame, 'unloaded', () => { - try { - console.log('[diag][frame] unloaded', { - ctor: frame?.constructor?.name, - }); - } catch {} - }); - } catch {} -} - -export function logUiSnapshot(reason: string) { - try { - const g: any = globalThis as any; - const F = getCore('Frame') || g.Frame; - const App = getCore('Application') || g.Application; - const top = F?.topmost?.(); - const rootView = App?.getRootView ? App.getRootView() : undefined; - const page = top?.currentPage || top?._currentEntry?.resolvedPage || null; - const info = { - reason, - placeholderActive: !!(g.__NS_DEV_PLACEHOLDER_ROOT_VIEW__ || g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__), - activityReady: !!(App?.android && (App.android.foregroundActivity || App.android.startActivity)), - topFrame: top - ? { - ctor: top.constructor?.name, - backstack: (top as any)?._backStack?.length || 0, - } - : null, - rootView: rootView ? { ctor: rootView.constructor?.name } : null, - currentPage: page - ? { - ctor: page.constructor?.name, - tag: (page as any)?.__ns_hmr_tag || (page as any)?.__ns_diag_tag, - title: (page as any)?.title, - } - : null, - }; - console.log('[diag][ui]', info); - } catch {} -} diff --git a/packages/vite/hmr/frameworks/vue/client/index.ts b/packages/vite/hmr/frameworks/vue/client/index.ts index 0614e0bf13..156a83a39f 100644 --- a/packages/vite/hmr/frameworks/vue/client/index.ts +++ b/packages/vite/hmr/frameworks/vue/client/index.ts @@ -1,4 +1,4 @@ -import { attachDiagnosticsToFrame, deriveHttpOrigin, getCore, getCurrentApp, getGraphVersion, getHMRWsUrl, getHttpOriginForVite, normalizeSpec, safeDynImport, safeReadDefault, setCurrentApp } from '../../../client/utils.js'; +import { deriveHttpOrigin, getCore, getCurrentApp, getGraphVersion, getHMRWsUrl, getHttpOriginForVite, normalizeSpec, safeDynImport, safeReadDefault, setCurrentApp } from '../../../client/utils.js'; // satisfied by define replacement declare const __NS_ENV_VERBOSE__: boolean | undefined; @@ -1099,9 +1099,6 @@ export function getRootForVue( // Treat Frame as authoritative root regardless of whether it already has a currentPage. // This avoids producing a Frame inside a wrapper Page which can lead to blank content in complex apps. if (ctorName === 'Frame' || /^Frame(\$\d+)?$/.test(ctorName)) { - try { - attachDiagnosticsToFrame(nativeView); - } catch {} if (__NS_ENV_VERBOSE__) console.log('[hmr-client] [createRoot] root kind=frame (adopting component Frame)'); state.setRootKind('frame'); state.setCachedRoot(nativeView); diff --git a/packages/vite/hmr/server/websocket.ts b/packages/vite/hmr/server/websocket.ts index d5a5f0caf8..1586831d46 100644 --- a/packages/vite/hmr/server/websocket.ts +++ b/packages/vite/hmr/server/websocket.ts @@ -1,22 +1,3 @@ -/* -RAW BYPASS DIAGNOSTICS (added): - Purpose: Fetch original Vite transform output (unsanitized) for differential comparison with sanitized/device-processed output. - Endpoints supporting ?raw=1: - - /ns/asm[/(ver)]?path=/abs/or/@/alias/Comp.vue&raw=1 - Returns either full compiled ?vue output (if available) or concatenated script/template variant transforms. - - /ns/sfc[/(ver)]?path=/abs/or/@/alias/Comp.vue[?vue&type=script|template]&raw=1 - Returns direct transformRequest result (before cleanCode/processCodeForDevice/rewriteImports delegation). - Response markers: - - // [sfc-asm] (raw bypass) - - // [sfc] raw bypass path= - - Hash banner: // [hash:] bytes= raw=1 - - X-NS-Source-Hash header mirrors hash for correlation with runtime compile logs. - Usage Workflow: - 1. Fetch sanitized module normally (without raw=1) and note its hash banner and failing runtime log containing [http-esm][compile][v8-error]. - 2. Fetch same URL with &raw=1 (or ?raw=1 if no existing query) to obtain unsanitized baseline. - 3. Diff raw vs sanitized focusing near reported line/column from v8-error log. - 4. Identify sanitation regex introducing syntax issue; adjust in cleanCode/processCodeForDevice. -*/ import type { Plugin, ViteDevServer, TransformResult } from 'vite'; import { createRequire } from 'node:module'; import { normalizeStrayCoreStringLiterals, fixDanglingCoreFrom, normalizeAnyCoreSpecToBridge } from './core-sanitize.js'; @@ -1163,7 +1144,7 @@ function processCodeForDevice(code: string, isVitePreBundled: boolean): string { 'const __ANDROID__ = globalThis.__ANDROID__ !== undefined ? globalThis.__ANDROID__ : false;', 'const __IOS__ = globalThis.__IOS__ !== undefined ? globalThis.__IOS__ : false;', 'const __VISIONOS__ = globalThis.__VISIONOS__ !== undefined ? globalThis.__VISIONOS__ : false;', - 'const __APPLE__ = globalThis.__APPLE__ !== undefined ? globalThis.__APPLE__ : false;', + 'const __APPLE__ = globalThis.__APPLE__ !== undefined ? globalThis.__APPLE__ : (__IOS__ || __VISIONOS__);', 'const __DEV__ = globalThis.__DEV__ !== undefined ? globalThis.__DEV__ : false;', 'const __COMMONJS__ = globalThis.__COMMONJS__ !== undefined ? globalThis.__COMMONJS__ : false;', 'const __NS_WEBPACK__ = globalThis.__NS_WEBPACK__ !== undefined ? globalThis.__NS_WEBPACK__ : true;', @@ -1249,6 +1230,15 @@ function processCodeForDevice(code: string, isVitePreBundled: boolean): string { // This allows the rewriter to see and canonicalize '/node_modules/.vite/deps/*' specifiers back // to their package ids (e.g., '@nativescript/firebase-core') and generate require-based bindings // so named imports like `{ firebase }` are preserved as const bindings. + // + // Some upstream transforms can emit a multiline form: + // import { x } from + // "/node_modules/.vite/deps/..."; + // If we don't normalize it, later stripping of naked string-only lines can leave + // an invalid `import ... from` statement. + try { + result = result.replace(/(^|\n)([\t ]*import\s+[^;]*?\s+from)\s*\n\s*("\/?node_modules\/\.vite\/deps\/[^"\n]+"\s*;?\s*)/gm, (_m, p1, p2, p3) => `${p1}${p2} ${p3}`); + } catch {} result = ensureNativeScriptModuleBindings(result); // Repair any accidental "import ... = expr" assignments that may have slipped in. @@ -2651,6 +2641,15 @@ function createHmrWebSocketPlugin(opts: { verbose?: boolean }): Plugin { return; } spec = spec.replace(/[?#].*$/, ''); + // Accept path-based HMR cache-busting: /ns/m/__ns_hmr__// + // The iOS HTTP ESM loader canonicalizes cache keys by stripping query params, + // so we must carry the cache-buster in the path. + try { + const m = spec.match(/^\/?__ns_hmr__\/[^\/]+(\/.*)?$/); + if (m) { + spec = m[1] || '/'; + } + } catch {} // Normalize absolute filesystem paths back to project-relative ids (e.g. /src/app.ts) try { const projectRoot = ((server as any).config?.root || process.cwd()) as string; @@ -2728,35 +2727,34 @@ function createHmrWebSocketPlugin(opts: { verbose?: boolean }): Plugin { } catch {} } } - // RAW BYPASS: allow fetching original Vite transform before sanitation for diffing/debugging - if (urlObj.searchParams.get('raw') === '1') { - const raw = transformed?.code || 'export {}\n'; - try { - const h = createHash('sha1').update(raw).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - res.statusCode = 200; - res.end(`// [hash:${h}] bytes=${raw.length} raw=1 m path=${spec}\n` + raw); - } catch { - res.statusCode = 200; - res.end(`// [raw=1] m path=${spec}\n` + raw); - } - return; - } - // Post-transform: inject cache-busting version for all internal /ns/m/* imports to avoid stale module reuse on device + // Post-transform: inject cache-busting version for all internal /ns/m/* imports to avoid stale module reuse on device. + // IMPORTANT: use PATH-based busting (not query) because the iOS HTTP ESM loader strips query params + // when computing module cache keys. try { if (transformed?.code) { const ver = Number((global as any).graphVersion || graphVersion || 0); let code = transformed.code; + const prefix = `/ns/m/__ns_hmr__/v${ver}`; + const rewrite = (p: string) => { + try { + if (!p || typeof p !== 'string') return p; + if (!p.startsWith('/ns/m/')) return p; + if (p.startsWith('/ns/m/__ns_hmr__/')) return p; + return prefix + p.slice('/ns/m'.length); + } catch { + return p; + } + }; // 1) Static imports: import ... from "/ns/m/..." - code = code.replace(/(from\s*["'])(\/ns\/m\/[^"'?]+)(["'])/g, `$1$2?v=${ver}$3`); + code = code.replace(/(from\s*["'])(\/ns\/m\/[^"'?]+)(["'])/g, (_m, a, p, b) => `${a}${rewrite(p)}${b}`); // 2) Side-effect imports: import "/ns/m/..." - code = code.replace(/(import\s*(?!\()\s*["'])(\/ns\/m\/[^"'?]+)(["'])/g, `$1$2?v=${ver}$3`); + code = code.replace(/(import\s*(?!\()\s*["'])(\/ns\/m\/[^"'?]+)(["'])/g, (_m, a, p, b) => `${a}${rewrite(p)}${b}`); // 3) Dynamic imports: import("/ns/m/...") - code = code.replace(/(import\(\s*["'])(\/ns\/m\/[^"'?]+)(["']\s*\))/g, `$1$2?v=${ver}$3`); + code = code.replace(/(import\(\s*["'])(\/ns\/m\/[^"'?]+)(["']\s*\))/g, (_m, a, p, b) => `${a}${rewrite(p)}${b}`); // 4) new URL("/ns/m/...", import.meta.url) - code = code.replace(/(new\s+URL\(\s*["'])(\/ns\/m\/[^"'?]+)(["']\s*,\s*import\.meta\.url\s*\))/g, `$1$2?v=${ver}$3`); + code = code.replace(/(new\s+URL\(\s*["'])(\/ns\/m\/[^"'?]+)(["']\s*,\s*import\.meta\.url\s*\))/g, (_m, a, p, b) => `${a}${rewrite(p)}${b}`); // 5) __ns_import(new URL('/ns/m/...', import.meta.url).href) - code = code.replace(/(new\s+URL\(\s*["'])(\/ns\/m\/[^"'?]+)(["']\s*,\s*import\.meta\.url\s*\)\.href)/g, `$1$2?v=${ver}$3`); + code = code.replace(/(new\s+URL\(\s*["'])(\/ns\/m\/[^"'?]+)(["']\s*,\s*import\.meta\.url\s*\)\.href)/g, (_m, a, p, b) => `${a}${rewrite(p)}${b}`); transformed.code = code; // TypeScript-specific graph population: when TS flavor is active // and this is an application module under the virtual app root, @@ -2879,15 +2877,10 @@ export const piniaSymbol = p.piniaSymbol; } catch {} } if (!transformed?.code) { - // Enhanced diagnostics: emit a module that throws with context for easier on-device debugging + // Emit a module that throws with context for easier on-device debugging try { const tried = Array.from(new Set(candidates)).slice(0, 12); - let out = `// [ns:m] transform miss path=${spec} tried=${tried.length}\n` + `throw new Error(${JSON.stringify(`[ns/m] transform failed for ${spec} (tried ${tried.length} candidates). Use ?raw=1 to inspect Vite output.`)});\nexport {};\n`; - try { - const h = createHash('sha1').update(out).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - out = `// [hash:${h}] bytes=${out.length}\n` + out; - } catch {} + const out = `// [ns:m] transform miss path=${spec} tried=${tried.length}\n` + `throw new Error(${JSON.stringify(`[ns/m] transform failed for ${spec} (tried ${tried.length} candidates).`)});\nexport {};\n`; res.statusCode = 404; res.end(out); return; @@ -3069,10 +3062,6 @@ export const piniaSymbol = p.piniaSymbol; console.warn('[ns:m][link-check] failed', (eLC as any)?.message || eLC); } catch {} } - try { - const h = createHash('sha1').update(code).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - } catch {} res.statusCode = 200; res.end(code); } catch (e) { @@ -3205,9 +3194,9 @@ export const piniaSymbol = p.piniaSymbol; `export const vShow = (__ensure().vShow);\n` + `export const createApp = (...a) => (__ensure().createApp)(...a);\n` + `export const registerElement = (...a) => (__ensure().registerElement)(...a);\n` + - `export const $navigateTo = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); if (g.__NS_VERBOSE_RT_NAV__ || g.__NS_DEV_LOGS__) console.log('[ns-rt] $navigateTo invoked'); try { if (!(g && g.Frame)) { const ns = (__ns_core_bridge && (__ns_core_bridge.__esModule && __ns_core_bridge.default ? __ns_core_bridge.default : (__ns_core_bridge.default || __ns_core_bridge))) || __ns_core_bridge || {}; if (ns) { if (!g.Frame && ns.Frame) g.Frame = ns.Frame; if (!g.Page && ns.Page) g.Page = ns.Page; if (!g.Application && (ns.Application||ns.app||ns.application)) g.Application = (ns.Application||ns.app||ns.application); } } } catch {} try { const hmrRealm = (g && g.__NS_HMR_REALM__) || 'unknown'; const hasTop = !!(g && g.Frame && g.Frame.topmost && g.Frame.topmost()); const top = hasTop ? g.Frame.topmost() : null; const ctor = top && top.constructor && top.constructor.name; if (g.__NS_VERBOSE_RT_NAV__ || g.__NS_DEV_LOGS__) { console.log('[ns-rt] $navigateTo(single-path)', { via: 'app', rtRealm: __RT_REALM_TAG, hmrRealm, hasTop, topCtor: ctor }); } } catch {} if (g && typeof g.__nsNavigateUsingApp === 'function') { try { return g.__nsNavigateUsingApp(...a); } catch (e) { try { console.error('[ns-rt] $navigateTo app navigator error', e); } catch {} throw e; } } try { console.error('[ns-rt] $navigateTo unavailable: app navigator missing'); } catch {} throw new Error('$navigateTo unavailable: app navigator missing'); } ;\n` + - `export const $navigateBack = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); const impl = (vm && (vm.$navigateBack || (vm.default && vm.default.$navigateBack))) || (rt && (rt.$navigateBack || (rt.runtimeHelpers && rt.runtimeHelpers.navigateBack))); let res; try { const via = (impl && (impl === (vm && vm.$navigateBack) || impl === (vm && vm.default && vm.default.$navigateBack))) ? 'vm' : (impl ? 'rt' : 'none'); if (globalThis && (globalThis.__NS_VERBOSE_RT_NAV__ || globalThis.__NS_DEV_LOGS__)) { console.log('[ns-rt] $navigateBack', { via }); } } catch {} try { if (typeof impl === 'function') res = impl(...a); } catch {} try { const top = (g && g.Frame && g.Frame.topmost && g.Frame.topmost()); if (!res && top && top.canGoBack && top.canGoBack()) { res = top.goBack(); } } catch {} try { const hook = g && (g.__NS_HMR_ON_NAVIGATE_BACK || g.__NS_HMR_ON_BACK || g.__nsAttemptBackRemount); if (typeof hook === 'function') hook(); } catch {} return res; }\n` + - `export const $showModal = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); const impl = (vm && (vm.$showModal || (vm.default && vm.default.$showModal))) || (rt && (rt.$showModal || (rt.runtimeHelpers && rt.runtimeHelpers.showModal))); try { if (typeof impl === 'function') return impl(...a); } catch (e) { try { if (g && (g.__NS_VERBOSE_RT_NAV__ || g.__NS_DEV_LOGS__)) { console.error('[ns-rt] $showModal error', e); } } catch {} } return undefined; }\n` + + `export const $navigateTo = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); try { if (!(g && g.Frame)) { const ns = (__ns_core_bridge && (__ns_core_bridge.__esModule && __ns_core_bridge.default ? __ns_core_bridge.default : (__ns_core_bridge.default || __ns_core_bridge))) || __ns_core_bridge || {}; if (ns) { if (!g.Frame && ns.Frame) g.Frame = ns.Frame; if (!g.Page && ns.Page) g.Page = ns.Page; if (!g.Application && (ns.Application||ns.app||ns.application)) g.Application = (ns.Application||ns.app||ns.application); } } } catch {} try { const hmrRealm = (g && g.__NS_HMR_REALM__) || 'unknown'; const hasTop = !!(g && g.Frame && g.Frame.topmost && g.Frame.topmost()); const top = hasTop ? g.Frame.topmost() : null; const ctor = top && top.constructor && top.constructor.name; } catch {} if (g && typeof g.__nsNavigateUsingApp === 'function') { try { return g.__nsNavigateUsingApp(...a); } catch (e) { try { console.error('[ns-rt] $navigateTo app navigator error', e); } catch {} throw e; } } try { console.error('[ns-rt] $navigateTo unavailable: app navigator missing'); } catch {} throw new Error('$navigateTo unavailable: app navigator missing'); } ;\n` + + `export const $navigateBack = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); const impl = (vm && (vm.$navigateBack || (vm.default && vm.default.$navigateBack))) || (rt && (rt.$navigateBack || (rt.runtimeHelpers && rt.runtimeHelpers.navigateBack))); let res; try { const via = (impl && (impl === (vm && vm.$navigateBack) || impl === (vm && vm.default && vm.default.$navigateBack))) ? 'vm' : (impl ? 'rt' : 'none'); } catch {} try { if (typeof impl === 'function') res = impl(...a); } catch {} try { const top = (g && g.Frame && g.Frame.topmost && g.Frame.topmost()); if (!res && top && top.canGoBack && top.canGoBack()) { res = top.goBack(); } } catch {} try { const hook = g && (g.__NS_HMR_ON_NAVIGATE_BACK || g.__NS_HMR_ON_BACK || g.__nsAttemptBackRemount); if (typeof hook === 'function') hook(); } catch {} return res; }\n` + + `export const $showModal = (...a) => { const vm = (__cached_vm || (void __ensure(), __cached_vm)); const rt = __ensure(); const impl = (vm && (vm.$showModal || (vm.default && vm.default.$showModal))) || (rt && (rt.$showModal || (rt.runtimeHelpers && rt.runtimeHelpers.showModal))); try { if (typeof impl === 'function') return impl(...a); } catch (e) { } return undefined; }\n` + `export default {\n` + ` defineComponent, resolveComponent, createVNode, createTextVNode, createCommentVNode,\n` + ` Fragment, Teleport, Transition, TransitionGroup, KeepAlive, Suspense, withCtx, openBlock,\n` + @@ -3286,11 +3275,6 @@ export const piniaSymbol = p.piniaSymbol; `const __core = new Proxy({}, { get(_t, p){ if (p === 'default') return __core; if (p === Symbol.toStringTag) return 'Module'; try { const v = g[p]; if (v !== undefined) return v; } catch {} try { const vc = __getVendorCore(); return vc ? vc[p] : undefined; } catch {} return undefined; } });\n` + `// Default export: namespace-like proxy\n` + `export default __core;\n`; - try { - const h = createHash('sha1').update(code).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - code = `// [hash:${h}] bytes=${code.length} core\n` + code; - } catch {} res.statusCode = 200; res.end(code); } catch (e) { @@ -3324,11 +3308,6 @@ export const piniaSymbol = p.piniaSymbol; } catch (e) { content = 'export default async function start(){ console.error("[/ns/entry-rt] not found"); }\n'; } - try { - const h = createHash('sha1').update(content).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - content = `// [hash:${h}] bytes=${content.length} entry-rt\n` + content; - } catch {} res.statusCode = 200; res.end(content); } catch (e) { @@ -3394,17 +3373,7 @@ export const piniaSymbol = p.piniaSymbol; ` const startEntry = (__mod && (__mod.default || __mod));\n` + ` try { await startEntry({ origin, main, ver: __ns_graph_ver, verbose: !!__VERBOSE__ }); if (__VERBOSE__) console.info('[ns-entry][wrapper] startEntry() resolved'); } catch (e) { console.error('[ns-entry][wrapper] startEntry() failed', e && (e.message||e)); throw e; }\n` + `})();\n`; - try { - const h = createHash('sha1').update(code).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - const banner = `// [hash:${h}] bytes=${code.length} entry\n`; - if (verbose) { - try { - console.log('[hmr-http] reply /ns/entry hash', h, 'bytes', code.length); - } catch {} - } - code = banner + code + `\n//# sourceURL=${origin}/ns/entry`; - } catch {} + code = code + `\n//# sourceURL=${origin}/ns/entry`; res.statusCode = 200; res.end(code); } catch (e) { @@ -3544,12 +3513,7 @@ export const piniaSymbol = p.piniaSymbol; // Emit an erroring module to surface the failure at import site with helpful hints try { const tried = candidates.slice(0, 8); - let out = `// [sfc] transform miss kind=full path=${fullSpec.replace(/\n/g, '')} tried=${tried.length}\n` + `throw new Error(${JSON.stringify('[ns/sfc] transform failed for full SFC: ' + fullSpec + ' (tried ' + tried.length + ')')});\nexport {}\n`; - try { - const h = createHash('sha1').update(out).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - out = `// [hash:${h}] bytes=${out.length}\n` + out; - } catch {} + const out = `// [sfc] transform miss kind=full path=${fullSpec.replace(/\n/g, '')} tried=${tried.length}\n` + `throw new Error(${JSON.stringify('[ns/sfc] transform failed for full SFC: ' + fullSpec + ' (tried ' + tried.length + ')')});\nexport {}\n`; res.statusCode = 404; res.end(out); return; @@ -3565,12 +3529,7 @@ export const piniaSymbol = p.piniaSymbol; } catch {} if (!transformed?.code) { try { - let out = `// [sfc] transform miss kind=variant path=${fullSpec.replace(/\n/g, '')}\n` + `throw new Error(${JSON.stringify('[ns/sfc] transform failed for variant: ' + fullSpec)});\nexport {}\n`; - try { - const h = createHash('sha1').update(out).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - out = `// [hash:${h}] bytes=${out.length}\n` + out; - } catch {} + const out = `// [sfc] transform miss kind=variant path=${fullSpec.replace(/\n/g, '')}\n` + `throw new Error(${JSON.stringify('[ns/sfc] transform failed for variant: ' + fullSpec)});\nexport {}\n`; res.statusCode = 404; res.end(out); return; @@ -3590,29 +3549,6 @@ export const piniaSymbol = p.piniaSymbol; return; } - // RAW BYPASS: serve unsanitized transform output (or direct transform of candidate) when ?raw=1 - const rawBypass = urlObj.searchParams.get('raw') === '1'; - if (rawBypass) { - try { - let rawOut = transformed.code || 'export {}\n'; - try { - const h = createHash('sha1').update(rawOut).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - rawOut = `// [hash:${h}] bytes=${rawOut.length} raw=1 sfc kind=${isVariant ? 'variant' : 'full'}\n` + rawOut; - } catch {} - res.statusCode = 200; - res.end(`// [sfc] raw bypass path=${fullSpec.replace(/\n/g, '')}\n` + rawOut); - return; - } catch (eRaw) { - try { - console.warn('[sfc][raw] failed', fullSpec, (eRaw as any)?.message); - } catch {} - res.statusCode = 200; - res.end('// [sfc] raw bypass error\nexport {}\n'); - return; - } - } - let code = transformed.code; // Prepend guard to capture any URL-based require attempts code = REQUIRE_GUARD_SNIPPET + code; @@ -3919,28 +3855,6 @@ export const piniaSymbol = p.piniaSymbol; code += `\nexport default (typeof __ns_sfc__ !== "undefined" ? __ns_sfc__ : (typeof _sfc_main !== "undefined" ? _sfc_main : undefined));`; } } - try { - const h = createHash('sha1').update(code).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - code = `// [hash:${h}] bytes=${code.length}\n` + code; - } catch {} - - // Diagnostic: when serving full SFCs, emit a short snippet and search for common compiled patterns - try { - if (!isVariant && verbose) { - const snippet = code.slice(0, 1024).replace(/\n/g, '\\n'); - const hasExportHelper = /_export_sfc\s*\(/.test(code); - const hasSfcMain = /_sfc_main\b/.test(code); - const hasNsReal = /__ns_real__\b/.test(code); - console.log(`[sfc][serve][diag] ${fullSpec} snippet=${snippet}`); - console.log(`[sfc][serve][diag] patterns exportHelper=${hasExportHelper} sfcMain=${hasSfcMain} mergedVar=${hasNsReal}`); - } - } catch (e) { - try { - console.warn('[sfc][serve][diag] print failed', e); - } catch {} - } - res.statusCode = 200; res.end(sig + code); } catch (e) { @@ -4076,43 +3990,6 @@ export const piniaSymbol = p.piniaSymbol; const scriptUrl = `${origin}/ns/sfc/${ver}${base}?vue&type=script`; const templateCode = templateR?.code || ''; - // RAW BYPASS: return unsanitized compiled SFC or script/template when ?raw=1 for differential debugging - const rawBypass = urlObj.searchParams.get('raw') === '1'; - if (rawBypass) { - try { - let rawOut = ''; - if (fullR?.code) { - rawOut = fullR.code; - } else if (scriptR?.code || templateR?.code) { - // Reconstruct minimal module if only variants available - const parts: string[] = []; - if (scriptR?.code) parts.push('// [raw][script]\n' + scriptR.code); - if (templateR?.code) parts.push('// [raw][template]\n' + templateR.code); - rawOut = parts.join('\n'); - } - if (!rawOut) { - rawOut = 'export {}\n'; - } - try { - const h = createHash('sha1').update(rawOut).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - rawOut = - `// [hash:${h}] bytes=${rawOut.length} raw=1 asm -` + rawOut; - } catch {} - res.statusCode = 200; - res.end(`// [sfc-asm] ${base} (raw bypass)\n` + rawOut); - return; - } catch (eRaw) { - try { - console.warn('[sfc-asm][raw] failed', base, (eRaw as any)?.message); - } catch {} - res.statusCode = 200; - res.end('// [sfc-asm] raw bypass error\nexport {}\n'); - return; - } - } - // INLINE-FIRST assembler: compile SFC source into a self-contained ESM module (enhanced diagnostics) try { const root = (server as any).config?.root || process.cwd(); @@ -4645,11 +4522,6 @@ export const piniaSymbol = p.piniaSymbol; inlineCode2 = inlineCode2.replace(/(^|[\n;])\s*var\s+__ns_sfc__\s*;?/g, '$1var __ns_sfc__ = {};'); } catch {} if (!/export\s+default\s+__ns_sfc__/.test(inlineCode2) && /__ns_sfc__/.test(inlineCode2)) inlineCode2 += '\nexport default __ns_sfc__'; - try { - const h = createHash('sha1').update(inlineCode2).digest('hex'); - res.setHeader('X-NS-Source-Hash', h); - inlineCode2 = `// [hash:${h}] bytes=${inlineCode2.length}\n` + inlineCode2; - } catch {} res.statusCode = 200; res.end(inlineCode2); return; From 49a16c550ef0f65533903890b44ca68c3b55354b Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 13 Dec 2025 08:21:21 -0800 Subject: [PATCH 07/13] feat(vite): onHmrUpdate for custom app hmr update handling --- packages/vite/hmr/shared/runtime/hooks.ts | 84 +++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/vite/hmr/shared/runtime/hooks.ts diff --git a/packages/vite/hmr/shared/runtime/hooks.ts b/packages/vite/hmr/shared/runtime/hooks.ts new file mode 100644 index 0000000000..77d7061f4d --- /dev/null +++ b/packages/vite/hmr/shared/runtime/hooks.ts @@ -0,0 +1,84 @@ +export type NsHmrUpdatePayload = { + type: 'full-graph' | 'delta'; + version: number; + changedIds: string[]; + // Raw message payload from the HMR WebSocket + raw: any; +}; + +export type NsHmrUpdateHandler = (payload: NsHmrUpdatePayload) => void; + +type NsHmrGlobalState = { + __NS_HMR_ON_UPDATE__?: unknown; + __NS_HMR_ON_UPDATE_DISPATCHER__?: NsHmrUpdateHandler; + __NS_HMR_ON_UPDATE_REGISTRY__?: Map; + __NS_HMR_ON_UPDATE_BASE__?: unknown; +}; + +function getNsHmrGlobal(): NsHmrGlobalState { + return globalThis as any; +} + +function ensureDispatcherInstalled(): { + registry: Map; + dispatcher: NsHmrUpdateHandler; + base: unknown; +} { + const g = getNsHmrGlobal(); + if (!g.__NS_HMR_ON_UPDATE_REGISTRY__) g.__NS_HMR_ON_UPDATE_REGISTRY__ = new Map(); + const registry = g.__NS_HMR_ON_UPDATE_REGISTRY__; + + if (!g.__NS_HMR_ON_UPDATE_DISPATCHER__) { + const base = g.__NS_HMR_ON_UPDATE__; + // If something already owns the hook and it's not our dispatcher, preserve it. + g.__NS_HMR_ON_UPDATE_BASE__ = base; + g.__NS_HMR_ON_UPDATE_DISPATCHER__ = (payload: NsHmrUpdatePayload) => { + // Call registered handlers first (app-level consumers). + try { + for (const handler of registry.values()) { + try { + handler(payload); + } catch {} + } + } catch {} + // Then call any preserved base hook. + try { + const b = (getNsHmrGlobal() as any).__NS_HMR_ON_UPDATE_BASE__; + if (typeof b === 'function') (b as NsHmrUpdateHandler)(payload); + } catch {} + }; + g.__NS_HMR_ON_UPDATE__ = g.__NS_HMR_ON_UPDATE_DISPATCHER__; + } + + return { + registry, + dispatcher: g.__NS_HMR_ON_UPDATE_DISPATCHER__!, + base: g.__NS_HMR_ON_UPDATE_BASE__, + }; +} + +/** + * Register a callback that will be invoked after each HMR batch + * (full graph or delta) is applied on device. + * + * It is safe to call multiple times with the same `id`; the handler + * will be replaced instead of stacking duplicates across module reloads. + */ + +export function onHmrUpdate(handler: NsHmrUpdateHandler, id: string): void { + if (typeof handler !== 'function') return; + if (typeof id !== 'string' || !id) return; + try { + const { registry } = ensureDispatcherInstalled(); + registry.set(id, handler); + } catch {} +} + +/** Remove a previously registered handler (use the same `id` you registered with). */ +export function offHmrUpdate(id: string): void { + if (typeof id !== 'string' || !id) return; + try { + const g = getNsHmrGlobal(); + g.__NS_HMR_ON_UPDATE_REGISTRY__?.delete(id); + } catch {} +} From 60b2f3d985a166223be91768743bcb9f5c61a4d1 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 13 Dec 2025 15:09:00 -0800 Subject: [PATCH 08/13] feat: improve hmr vite server ip connection cases --- packages/vite/helpers/main-entry.ts | 21 +++++++++++- packages/vite/hmr/entry-runtime.ts | 17 +++++++++- packages/vite/hmr/server/vite-plugin.ts | 33 ++++++++++++++++--- .../vite/hmr/shared/runtime/http-only-boot.ts | 21 ++++++++++-- 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/packages/vite/helpers/main-entry.ts b/packages/vite/helpers/main-entry.ts index 4ffb2fa9a5..d666ff5b69 100644 --- a/packages/vite/helpers/main-entry.ts +++ b/packages/vite/helpers/main-entry.ts @@ -1,5 +1,6 @@ import { getPackageJson, getProjectFilePath, getProjectRootPath } from './project.js'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import { getProjectFlavor } from './flavor.js'; import { getProjectAppPath, getProjectAppRelativePath, getProjectAppVirtualPath } from './utils.js'; @@ -226,7 +227,25 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos' if (opts.verbose) { imports += `console.info('[ns-entry] including HTTP-only boot', { platform: ${JSON.stringify(opts.platform)}, mainRel: ${JSON.stringify(mainEntryRelPosix)} });\n`; } - const defaultHost = opts.platform === 'android' ? '10.0.2.2' : 'localhost'; + const guessLanHost = (): string | undefined => { + try { + const nets = os.networkInterfaces(); + for (const name of Object.keys(nets)) { + const addrs = nets[name] || []; + for (const a of addrs) { + if (!a) continue; + const family = (a as any).family; + const internal = !!(a as any).internal; + const address = String((a as any).address || ''); + if (internal) continue; + if ((family === 'IPv4' || family === 4) && address && address !== '127.0.0.1') return address; + } + } + } catch {} + return undefined; + }; + // Prefer LAN IP so physical devices work by default; emulator will still be tried as a fallback. + const defaultHost = opts.platform === 'android' ? guessLanHost() || '10.0.2.2' : guessLanHost() || 'localhost'; imports += "import { startHttpOnlyBoot } from '@nativescript/vite/hmr/shared/runtime/http-only-boot.js';\n"; imports += `startHttpOnlyBoot(${JSON.stringify(opts.platform)}, ${JSON.stringify(mainEntryRelPosix)}, ${JSON.stringify((process.env.NS_HMR_HOST || '') as string) || JSON.stringify('')} || ${JSON.stringify(defaultHost)}, __nsVerboseLog);\n`; if (opts.verbose) { diff --git a/packages/vite/hmr/entry-runtime.ts b/packages/vite/hmr/entry-runtime.ts index 790e94c882..4b614d55d6 100644 --- a/packages/vite/hmr/entry-runtime.ts +++ b/packages/vite/hmr/entry-runtime.ts @@ -81,7 +81,20 @@ export default async function startEntry(opts: EntryOpts) { if (VERBOSE) console.info('[ns-entry] entry importing', MAIN_URL); (globalThis as any).__NS_ENTRY_LAST_TARGET__ = MAIN_URL; // used by fetchCodeframe sanitized-vs-raw tag const t_main = Date.now(); - await importHttp(MAIN_URL); + let lastMainErr: any = null; + for (let attempt = 0; attempt < 6; attempt++) { + try { + const url = attempt === 0 ? MAIN_URL : MAIN_URL + '&r=' + String(Date.now()); + await importHttp(url); + lastMainErr = null; + break; + } catch (e_main: any) { + lastMainErr = e_main; + // brief backoff; allows dev server and device network to settle + await new Promise((r) => setTimeout(r, 150 + attempt * 150)); + } + } + if (lastMainErr) throw lastMainErr; TRACE.main = { ok: true, ms: Date.now() - t_main, url: MAIN_URL }; (globalThis as any).__NS_ENTRY_OK__ = true; @@ -122,6 +135,8 @@ export default async function startEntry(opts: EntryOpts) { if (VERBOSE) console.info('[ns-entry][diag] Tip: append ?raw=1 to /ns/m, /ns/sfc, or /ns/asm URLs to compare raw vs sanitized output.'); } catch {} (globalThis as any).__NS_ENTRY_OK__ = false; + // Re-throw so the HTTP bootloader can try other origin candidates. + throw e; } finally { try { TRACE.t1 = Date.now(); diff --git a/packages/vite/hmr/server/vite-plugin.ts b/packages/vite/hmr/server/vite-plugin.ts index c7465e43d7..ea39d46a52 100644 --- a/packages/vite/hmr/server/vite-plugin.ts +++ b/packages/vite/hmr/server/vite-plugin.ts @@ -1,6 +1,7 @@ import type { Plugin, ResolvedConfig } from 'vite'; import { createRequire } from 'node:module'; import path from 'path'; +import os from 'os'; import { pathToFileURL } from 'url'; const require = createRequire(import.meta.url); @@ -10,6 +11,27 @@ const RESOLVED_ID = '\0' + VIRTUAL_ID; export function nsHmrClientVitePlugin(opts: { platform: string; verbose?: boolean }): Plugin { let config: ResolvedConfig | undefined; + const guessLanHost = (): string | undefined => { + try { + const nets = os.networkInterfaces(); + for (const name of Object.keys(nets)) { + const addrs = nets[name] || []; + for (const a of addrs) { + if (!a) continue; + // Node typings vary across versions; keep checks defensive + const family = (a as any).family; + const internal = !!(a as any).internal; + const address = String((a as any).address || ''); + if (internal) continue; + if (family === 'IPv4' || family === 4) { + if (address && address !== '127.0.0.1') return address; + } + } + } + } catch {} + return undefined; + }; + return { name: 'ns-hmr-client', configResolved(c) { @@ -55,11 +77,14 @@ export function nsHmrClientVitePlugin(opts: { platform: string; verbose?: boolea // Build ws url from Vite server info let host = process.env.NS_HMR_HOST || (config?.server?.host as any); - // Android emu special-case - if (opts.platform === 'android' && (host === '0.0.0.0' || !host || host === true)) { - host = '10.0.2.2'; + // If Vite is bound to all interfaces, prefer a LAN IP so physical devices work. + // The HMR client will still try emulator/localhost fallbacks when needed. + const hostStr = typeof host === 'string' ? host : ''; + const isWildcard = host === true || hostStr === '0.0.0.0' || hostStr === '::' || hostStr === ''; + if (isWildcard) { + host = guessLanHost() || (opts.platform === 'android' ? '10.0.2.2' : 'localhost'); } else if (!host) { - host = 'localhost'; + host = opts.platform === 'android' ? guessLanHost() || '10.0.2.2' : 'localhost'; } const port = Number(config?.server?.port || 5173); const secure = !!config?.server?.https; diff --git a/packages/vite/hmr/shared/runtime/http-only-boot.ts b/packages/vite/hmr/shared/runtime/http-only-boot.ts index ec8008b3f5..92f6e46afa 100644 --- a/packages/vite/hmr/shared/runtime/http-only-boot.ts +++ b/packages/vite/hmr/shared/runtime/http-only-boot.ts @@ -46,10 +46,25 @@ export async function startHttpOnlyBoot(platform: 'ios' | 'android' | 'visionos' const buildOrigins = () => { const origins: string[] = []; if ((globalThis as any)['__NS_HTTP_ORIGIN__']) origins.push((globalThis as any)['__NS_HTTP_ORIGIN__']); - const androidExtras = platform === 'android' ? ['10.0.2.2', 'localhost'] : ['localhost']; - const hostCandidates = Array.from(new Set([host, ...androidExtras])); + const hostCandidates: string[] = []; + // Only accept a concrete host string from the default; ignore wildcard bind addresses. + try { + const h = String(host || ''); + if (h && h !== '0.0.0.0' && h !== '::' && h !== 'true') hostCandidates.push(h); + } catch {} + // Always try loopback variants + hostCandidates.push('localhost'); + if (platform === 'android') { + // Physical device via `adb reverse` often works best with 127.0.0.1 + hostCandidates.push('127.0.0.1'); + // Stock Android emulator host loopback + hostCandidates.push('10.0.2.2'); + // Genymotion host loopback + hostCandidates.push('10.0.3.2'); + } + const dedupedHosts = Array.from(new Set(hostCandidates)); for (const p of protoCandidates) { - for (const h of hostCandidates) origins.push(p + '://' + h + ':' + port); + for (const h of dedupedHosts) origins.push(p + '://' + h + ':' + port); } return Array.from(new Set(origins)); }; From 9df273fcf0ae1cc86f2cbdef0cde49302f48690b Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 16 Dec 2025 19:11:18 -0800 Subject: [PATCH 09/13] feat: improvements for import.meta.hot --- packages/vite/hmr/helpers/ast-normalizer.ts | 9 +++++++++ packages/vite/hmr/server/constants.ts | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/vite/hmr/helpers/ast-normalizer.ts b/packages/vite/hmr/helpers/ast-normalizer.ts index 2adb2859b7..793b0406ed 100644 --- a/packages/vite/hmr/helpers/ast-normalizer.ts +++ b/packages/vite/hmr/helpers/ast-normalizer.ts @@ -103,6 +103,15 @@ export function astNormalizeModuleImportsAndHelpers(code: string): string { // Keep other prebundles as-is; they may be fetchable directly from the dev server } } else if (isViteVirtual(src) || src === '@vite/client' || src === '/@vite/client') { + // Removing the Vite client import should also remove its declared locals from our + // `declared` set; otherwise later bridge rewrites may unnecessarily suffix names + // (e.g. `__vite__createHotContext_1`) while call sites still reference the original. + try { + for (const s of path.node.specifiers || []) { + const local = (s as any)?.local?.name; + if (typeof local === 'string' && local) declared.delete(local); + } + } catch {} path.remove(); return; } diff --git a/packages/vite/hmr/server/constants.ts b/packages/vite/hmr/server/constants.ts index c4d065aa6d..a58b6a5de6 100644 --- a/packages/vite/hmr/server/constants.ts +++ b/packages/vite/hmr/server/constants.ts @@ -19,7 +19,10 @@ export const VUE_FILE_IMPORT = /(?:^|\n)(\s*import\s+[^'";]*?\s+from\s+["'])([^" // Vite/HMR noise cleanup export const VITE_CLIENT_IMPORT = /(?:^|\n)\s*import\s+['"](?:\/@vite\/client|@vite\/client)['"];?/g; -export const IMPORT_META_HOT_ASSIGNMENT = /(?:^|\n)\s*import\.meta\.hot\s*=\s*[^;\n]+;?/g; +// Strip Vite's injected `import.meta.hot = __vite__createHotContext(...)` assignment. +// Important: it may appear mid-line after other tokens/spaces in sanitized HTTP output, +// so we cannot rely on it starting at BOL. +export const IMPORT_META_HOT_ASSIGNMENT = /\bimport\.meta\.hot\s*=\s*[^;\n]+;?/g; export const IMPORT_META_HOT_CALLS = /(?:^|\n)\s*import\.meta\.hot\.[A-Za-z_$][\w$]*\([^)]*\);?\s*/g; // Remove only Vue style virtual imports; keep script/template variants From 8c57189db60b58389c55e1ea757db4bed63305d2 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 21 Dec 2025 12:38:00 -0800 Subject: [PATCH 10/13] chore: oidc workflow improvements --- .github/workflows/secure_nx_release.yml | 36 +++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/secure_nx_release.yml b/.github/workflows/secure_nx_release.yml index dfd18ea6c8..3bc5509da6 100644 --- a/.github/workflows/secure_nx_release.yml +++ b/.github/workflows/secure_nx_release.yml @@ -282,7 +282,7 @@ jobs: npx nx release publish --tag "${{ steps.ctx.outputs.dist_tag }}" --access public --verbose --dry-run # Tag-triggered publishing: publish the single package referenced by the tag. - - name: nx release publish (tag) + - name: Build and Publish (tag) if: ${{ steps.ctx.outputs.mode == 'tag' && vars.USE_NPM_TOKEN != 'true' }} shell: bash env: @@ -296,19 +296,39 @@ jobs: rm -f "$NPM_CONFIG_USERCONFIG" || true fi - npx nx release publish \ - --projects "${{ steps.taginfo.outputs.project }}" \ - --tag "${{ steps.taginfo.outputs.dist_tag }}" \ - --access public \ - --verbose + project="${{ steps.taginfo.outputs.project }}" + dist_tag="${{ steps.taginfo.outputs.dist_tag }}" + + echo "Building $project..." + npx nx build "$project" --skip-nx-cache + + echo "Publishing from dist/packages/$project..." + cd "dist/packages/$project" + + npm pack + tgz_file=$(ls *.tgz | head -n 1) + npm publish "$tgz_file" --tag "$dist_tag" --access public --verbose - - name: nx release publish (tag, token) + - name: Build and Publish (tag, token) if: ${{ steps.ctx.outputs.mode == 'tag' && vars.USE_NPM_TOKEN == 'true' }} + shell: bash env: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} NPM_CONFIG_PROVENANCE: true run: | - npx nx release publish --projects "${{ steps.taginfo.outputs.project }}" --tag "${{ steps.taginfo.outputs.dist_tag }}" --access public --verbose + set -euo pipefail + project="${{ steps.taginfo.outputs.project }}" + dist_tag="${{ steps.taginfo.outputs.dist_tag }}" + + echo "Building $project..." + npx nx build "$project" --skip-nx-cache + + echo "Publishing from dist/packages/$project..." + cd "dist/packages/$project" + + npm pack + tgz_file=$(ls *.tgz | head -n 1) + npm publish "$tgz_file" --tag "$dist_tag" --access public --verbose - name: Summary if: always() From d472881eadbaae6de7a379e4bc621729582fe0a2 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 21 Dec 2025 12:44:51 -0800 Subject: [PATCH 11/13] fix(vite): ensure angular tsconfig prefers .app.json and falls back to no suffix --- packages/vite/configuration/angular.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/vite/configuration/angular.ts b/packages/vite/configuration/angular.ts index 84f14b790f..6e6d6cb328 100644 --- a/packages/vite/configuration/angular.ts +++ b/packages/vite/configuration/angular.ts @@ -1,5 +1,6 @@ import { mergeConfig, type UserConfig, type Plugin } from 'vite'; import path from 'path'; +import fs from 'node:fs'; import { createRequire } from 'node:module'; import angular from '@analogjs/vite-plugin-angular'; import { angularLinkerVitePlugin, angularLinkerVitePluginPost } from '../helpers/angular/angular-linker.js'; @@ -108,6 +109,14 @@ const cliFlags = getCliFlags(); const isDevEnv = process.env.NODE_ENV !== 'production'; const hmrActive = isDevEnv && !!cliFlags.hmr; +const projectRoot = process.cwd(); +const tsConfigAppPath = path.resolve(projectRoot, 'tsconfig.app.json'); +const tsConfigPath = path.resolve(projectRoot, 'tsconfig.json'); +let tsConfig = tsConfigAppPath; +if (!fs.existsSync(tsConfigAppPath) && fs.existsSync(tsConfigPath)) { + tsConfig = tsConfigPath; +} + const plugins = [ // Allow external html template changes to trigger hot reload: Make .ts files depend on their .html templates { @@ -132,6 +141,7 @@ const plugins = [ // angularRollupLinker(process.cwd()), angular({ liveReload: false, // Disable live reload in favor of HMR + tsconfig: tsConfig, }), // Post-phase linker to catch any declarations introduced after other transforms (including project code) angularLinkerVitePluginPost(process.cwd()), From 5083f680f304ddc78f362cd0d18ffdbcd9e80c07 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 21 Dec 2025 22:49:23 -0800 Subject: [PATCH 12/13] fix: init without debug logs by default --- packages/vite/helpers/init.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vite/helpers/init.ts b/packages/vite/helpers/init.ts index deae3fe2d0..79de7a89e8 100644 --- a/packages/vite/helpers/init.ts +++ b/packages/vite/helpers/init.ts @@ -52,10 +52,10 @@ function ensureScripts(pkg: PackageJson) { pkg.scripts = pkg.scripts ?? {}; pkg.scripts['dev:ios'] = "concurrently -k -n vite,ns 'npm run dev:server:ios' 'wait-on tcp:5173 && npm run ios'"; pkg.scripts['dev:android'] = "concurrently -k -n vite,ns 'npm run dev:server:android' 'wait-on tcp:5173 && npm run android'"; - pkg.scripts['dev:server:ios'] = 'VITE_DEBUG_LOGS=1 vite serve -- --env.ios --env.hmr'; - pkg.scripts['dev:server:android'] = 'VITE_DEBUG_LOGS=1 vite serve -- --env.android --env.hmr'; - pkg.scripts['ios'] = 'VITE_DEBUG_LOGS=1 ns debug ios'; - pkg.scripts['android'] = 'VITE_DEBUG_LOGS=1 ns debug android'; + pkg.scripts['dev:server:ios'] = 'vite serve -- --env.ios --env.hmr'; + pkg.scripts['dev:server:android'] = 'vite serve -- --env.android --env.hmr'; + pkg.scripts['ios'] = 'ns debug ios'; + pkg.scripts['android'] = 'ns debug android'; } function ensureGitignore() { From 3c88d64114ff48ff1241adefaa37d1d678a6e697 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 3 Jan 2026 21:12:46 -0800 Subject: [PATCH 13/13] feat(vite): angular hmr --- packages/core/globals/index.ts | 8 +- packages/core/xhr/index.ts | 146 +-- packages/vite/configuration/angular.ts | 108 +- packages/vite/configuration/base.ts | 12 +- .../vite/helpers/angular/angular-linker.ts | 5 +- .../vite/helpers/angular/shared-linker.ts | 14 +- packages/vite/helpers/commonjs-plugins.ts | 129 ++- packages/vite/helpers/main-entry.ts | 53 +- packages/vite/hmr/client/index.ts | 58 +- packages/vite/hmr/client/utils.ts | 68 ++ .../hmr/frameworks/angular/client/hmr-v2.ts | 677 +++++++++++ .../hmr/frameworks/angular/client/index.ts | 1004 ++++++++++++++++- .../hmr/server/angular-hmr-transformer.ts | 220 ++++ packages/vite/hmr/server/websocket.ts | 511 ++++++++- 14 files changed, 2796 insertions(+), 217 deletions(-) create mode 100644 packages/vite/hmr/frameworks/angular/client/hmr-v2.ts create mode 100644 packages/vite/hmr/server/angular-hmr-transformer.ts diff --git a/packages/core/globals/index.ts b/packages/core/globals/index.ts index 70be070000..0582bb12c8 100644 --- a/packages/core/globals/index.ts +++ b/packages/core/globals/index.ts @@ -316,7 +316,9 @@ if (!global.NativeScriptHasPolyfilled) { installPolyfills('text', ['TextDecoder', 'TextEncoder']); global.registerModule('xhr', () => xhrImpl); - installPolyfills('xhr', ['XMLHttpRequest', 'FormData', 'Blob', 'File', 'FileReader']); + // Blob and File are now provided by the runtime with a complete File API spec implementation + // Only install XMLHttpRequest, FormData, and FileReader from xhrImpl + installPolyfills('xhr', ['XMLHttpRequest', 'FormData', 'FileReader']); global.registerModule('fetch', () => fetchPolyfill); installPolyfills('fetch', ['fetch', 'Headers', 'Request', 'Response']); @@ -351,8 +353,8 @@ if (!global.NativeScriptHasPolyfilled) { // xhr glb.XMLHttpRequest = xhrImpl.XMLHttpRequest; glb.FormData = xhrImpl.FormData; - glb.Blob = xhrImpl.Blob; - glb.File = xhrImpl.File; + // Blob and File are now provided by the runtime with a complete File API spec implementation + // Only install FileReader from xhrImpl (Blob and File come from the runtime) glb.FileReader = xhrImpl.FileReader; // fetch diff --git a/packages/core/xhr/index.ts b/packages/core/xhr/index.ts index 8ffedac499..046aaed618 100644 --- a/packages/core/xhr/index.ts +++ b/packages/core/xhr/index.ts @@ -242,7 +242,7 @@ export class XMLHttpRequest { this._options.content = (data).toString(); } else if (data instanceof Blob) { this.setRequestHeader('Content-Type', data.type); - this._options.content = Blob.InternalAccessor.getBuffer(data); + this._options.content = BlobInternalAccessor.getBuffer(data); } else if (data instanceof ArrayBuffer) { this._options.content = data; } @@ -375,113 +375,41 @@ export class FormData { } } -export class Blob { - // Note: only for use by XHR - public static InternalAccessor = class { - public static getBuffer(blob: Blob) { - return blob._buffer; +// Blob and File are now provided by the runtime (Runtime.mm) with a complete +// File API spec implementation. We just need a helper to access internal bytes +// for XHR and FileReader operations. +// The runtime stores blob data using a Symbol accessible via globalThis.__BLOB_INTERNALS__ + +/** + * Helper to access internal Blob buffer for XHR and FileReader. + * Works with the runtime's Blob implementation which stores data + * using the __BLOB_INTERNALS__ symbol. + */ +export const BlobInternalAccessor = { + getBuffer(blob: Blob): Uint8Array { + // Access the runtime's internal symbol for Blob data + const internalsSymbol = (globalThis as any).__BLOB_INTERNALS__; + if (internalsSymbol && blob[internalsSymbol]) { + return blob[internalsSymbol].bytes; } - }; - - private _buffer: Uint8Array; - private _size: number; - private _type: string; - - public get size() { - return this._size; - } - public get type() { - return this._type; - } - - constructor(chunks: Array = [], opts: { type?: string } = {}) { - const dataChunks: Uint8Array[] = []; - for (const chunk of chunks) { - if (chunk instanceof Blob) { - dataChunks.push(chunk._buffer); - } else if (typeof chunk === 'string') { - const textEncoder = new TextEncoder(); - dataChunks.push(textEncoder.encode(chunk)); - } else if (chunk instanceof DataView) { - dataChunks.push(new Uint8Array(chunk.buffer.slice(0))); - } else if (chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk)) { - dataChunks.push(new Uint8Array(ArrayBuffer.isView(chunk) ? chunk.buffer.slice(0) : chunk.slice(0))); - } else { - const textEncoder = new TextEncoder(); - dataChunks.push(textEncoder.encode(String(chunk))); - } - } - - const size = dataChunks.reduce((size, chunk) => size + chunk.byteLength, 0); - const buffer = new Uint8Array(size); - let offset = 0; - for (let i = 0; i < dataChunks.length; i++) { - const chunk = dataChunks[i]; - buffer.set(chunk, offset); - offset += chunk.byteLength; - } - - this._buffer = buffer; - this._size = this._buffer.byteLength; - - this._type = opts.type || ''; - if (/[^\u0020-\u007E]/.test(this._type)) { - this._type = ''; - } else { - this._type = this._type.toLowerCase(); - } - } - - public arrayBuffer() { - return Promise.resolve(this._buffer); - } - - public text(): Promise { - const textDecoder = new TextDecoder(); - - return Promise.resolve(textDecoder.decode(this._buffer)); - } - - public slice(start?: number, end?: number, type?: string): Blob { - const slice = this._buffer.slice(start || 0, end || this._buffer.length); - - return new Blob([slice], { type: type }); - } - - public stream() { - throw new Error('stream is currently not supported'); - } - - public toString() { - return '[object Blob]'; - } - - [Symbol.toStringTag] = 'Blob'; -} - -export class File extends Blob { - private _name: string; - private _lastModified: number; - - public get name() { - return this._name; - } - - public get lastModified() { - return this._lastModified; - } - - constructor(chunks: Array, name: string, opts: { type?: string; lastModified?: number } = {}) { - super(chunks, opts); - this._name = name.replace(/\//g, ':'); - this._lastModified = opts.lastModified ? new Date(opts.lastModified).valueOf() : Date.now(); - } - - public toString() { - return '[object File]'; - } + // Fallback for any edge cases + return new Uint8Array(0); + }, +}; - [Symbol.toStringTag] = 'File'; +// Re-export Blob and File from globalThis for backwards compatibility +// These are defined by the runtime +export const Blob = globalThis.Blob; +export const File = globalThis.File; + +// Backwards compatibility: Add InternalAccessor to the exported Blob +// Some code may still reference Blob.InternalAccessor +if (Blob && !(Blob as any).InternalAccessor) { + (Blob as any).InternalAccessor = { + getBuffer(blob: Blob): Uint8Array { + return BlobInternalAccessor.getBuffer(blob); + }, + }; } export class FileReader { @@ -587,18 +515,18 @@ export class FileReader { public readAsDataURL(blob: Blob) { this._read(blob, 'readAsDataURL'); - this._result = `data:${blob.type};base64,${this._array2base64(Blob.InternalAccessor.getBuffer(blob))}`; + this._result = `data:${blob.type};base64,${this._array2base64(BlobInternalAccessor.getBuffer(blob))}`; } public readAsText(blob: Blob) { this._read(blob, 'readAsText'); const textDecoder = new TextDecoder(); - this._result = textDecoder.decode(Blob.InternalAccessor.getBuffer(blob)); + this._result = textDecoder.decode(BlobInternalAccessor.getBuffer(blob)); } public readAsArrayBuffer(blob: Blob) { this._read(blob, 'readAsArrayBuffer'); - this._result = Blob.InternalAccessor.getBuffer(blob).buffer.slice(0); + this._result = BlobInternalAccessor.getBuffer(blob).buffer.slice(0); } public abort() { diff --git a/packages/vite/configuration/angular.ts b/packages/vite/configuration/angular.ts index 6e6d6cb328..dcf64c8908 100644 --- a/packages/vite/configuration/angular.ts +++ b/packages/vite/configuration/angular.ts @@ -117,10 +117,16 @@ if (!fs.existsSync(tsConfigAppPath) && fs.existsSync(tsConfigPath)) { tsConfig = tsConfigPath; } +// Track HTML template -> TS component relationships for watch mode rebuilds +const templateToComponentMap = new Map(); +// Track which TS files need recompilation due to HTML changes +const pendingHtmlChanges = new Set(); + const plugins = [ // Allow external html template changes to trigger hot reload: Make .ts files depend on their .html templates { name: 'angular-template-deps', + enforce: 'pre', transform(code, id) { // For .ts files that reference templateUrl, add the .html file as a dependency if (id.endsWith('.ts') && code.includes('templateUrl')) { @@ -129,10 +135,37 @@ const plugins = [ const htmlPath = path.resolve(path.dirname(id), templateUrlMatch[1]); // Add the HTML file as a dependency so Vite watches it this.addWatchFile(htmlPath); + // Track the relationship for build watch mode + templateToComponentMap.set(htmlPath, id); } } return null; }, + // Handle file changes in build watch mode - queue TS files for recompilation + watchChange(id, change) { + // When an HTML template changes, mark the TS component for recompilation + if (id.endsWith('.html') && templateToComponentMap.has(id)) { + const tsPath = templateToComponentMap.get(id); + if (tsPath) { + pendingHtmlChanges.add(tsPath); + } + } + }, + // On build start, touch any TS files that had HTML changes + buildStart() { + if (pendingHtmlChanges.size > 0) { + const now = new Date(); + for (const tsPath of pendingHtmlChanges) { + try { + // Touch the TS file to force Angular to recompile it + fs.utimesSync(tsPath, now, now); + } catch (e) { + // Ignore errors + } + } + pendingHtmlChanges.clear(); + } + }, }, // Transform Angular partial declarations in node_modules to avoid runtime JIT // Pass the project root so linker deps resolve from the app, not the plugin package. @@ -140,7 +173,7 @@ const plugins = [ // Simplify: rely on Vite pre plugin (load/transform) for linking; Rollup safety net disabled unless re-enabled later // angularRollupLinker(process.cwd()), angular({ - liveReload: false, // Disable live reload in favor of HMR + liveReload: true, // Enable live reload with ɵɵreplaceMetadata for component HMR tsconfig: tsConfig, }), // Post-phase linker to catch any declarations introduced after other transforms (including project code) @@ -194,21 +227,33 @@ export const angularConfig = ({ mode }): UserConfig => { apply: 'build' as const, enforce: 'post' as const, async generateBundle(_options, bundle) { + const debug = process.env.VITE_DEBUG_LOGS === '1' || process.env.VITE_DEBUG_LOGS === 'true'; + if (debug) { + console.log('[ns-angular-linker-post] generateBundle called with', Object.keys(bundle).length, 'files'); + } function isNsAngularPolyfillsChunk(chunk: any): boolean { if (!chunk || !(chunk as any).modules) return false; return Object.keys((chunk as any).modules).some((m) => m.includes('node_modules/@nativescript/angular/fesm2022/nativescript-angular-polyfills.mjs')); } const { babel, linkerPlugin } = await ensureSharedAngularLinker(process.cwd()); - if (!babel || !linkerPlugin) return; + if (!babel || !linkerPlugin) { + if (debug) { + console.log('[ns-angular-linker-post] babel or linkerPlugin not available'); + } + return; + } const strict = process.env.NS_STRICT_NG_LINK === '1' || process.env.NS_STRICT_NG_LINK === 'true'; const enforceStrict = process.env.NS_STRICT_NG_LINK_ENFORCE === '1' || process.env.NS_STRICT_NG_LINK_ENFORCE === 'true'; - const debug = process.env.VITE_DEBUG_LOGS === '1' || process.env.VITE_DEBUG_LOGS === 'true'; const unlinked: string[] = []; for (const [fileName, chunk] of Object.entries(bundle)) { if (!fileName.endsWith('.mjs') && !fileName.endsWith('.js')) continue; if (chunk && (chunk as any).type === 'chunk') { const code = (chunk as any).code as string; if (!code) continue; + const hasNgDeclare = containsRealNgDeclare(code); + if (debug) { + console.log('[ns-angular-linker-post] checking', fileName, 'hasNgDeclare:', hasNgDeclare); + } const isNsPolyfills = isNsAngularPolyfillsChunk(chunk); try { const res = await (babel as any).transformAsync(code, { @@ -220,6 +265,10 @@ export const angularConfig = ({ mode }): UserConfig => { plugins: [linkerPlugin], }); const finalCode = res?.code && res.code !== code ? res.code : code; + const stillHasNgDeclare = containsRealNgDeclare(finalCode); + if (debug) { + console.log('[ns-angular-linker-post] after linking', fileName, 'changed:', finalCode !== code, 'stillHasNgDeclare:', stillHasNgDeclare); + } if (finalCode !== code) { (chunk as any).code = finalCode; if (debug) { @@ -228,10 +277,13 @@ export const angularConfig = ({ mode }): UserConfig => { } catch {} } } - if (strict && !isNsPolyfills && containsRealNgDeclare(finalCode)) { + if (strict && !isNsPolyfills && stillHasNgDeclare) { unlinked.push(fileName); } - } catch { + } catch (err) { + if (debug) { + console.log('[ns-angular-linker-post] error linking', fileName, err); + } if (strict) unlinked.push(fileName); } } @@ -315,35 +367,33 @@ export const angularConfig = ({ mode }): UserConfig => { const enableRollupLinker = process.env.NS_ENABLE_ROLLUP_LINKER === '1' || process.env.NS_ENABLE_ROLLUP_LINKER === 'true' || hmrActive; + // Build combined aliases + const resolveAlias: Array<{ find: RegExp | string; replacement: string }> = [ + // Map Angular deep ESM paths to bare package ids - MUST be first for priority + { find: /^@angular\/([^/]+)\/fesm2022\/.*\.mjs$/, replacement: '@angular/$1' }, + { find: /^@nativescript\/angular\/fesm2022\/.*\.mjs$/, replacement: '@nativescript/angular' }, + // Note: RxJS 7.x uses dist/esm for modern builds; let Vite resolve via package.json exports + ]; + // Add animation shims if animations are disabled + if (disableAnimations) { + resolveAlias.push( + { + find: /^@angular\/animations(\/.+)?$/, // match subpaths too + replacement: new URL('../shims/angular-animations-stub.js', import.meta.url).pathname, + }, + { + find: /^@angular\/platform-browser\/animations(\/.+)?$/, + replacement: new URL('../shims/angular-animations-stub.js', import.meta.url).pathname, + }, + ); + } + return mergeConfig(baseConfig({ mode, flavor: 'angular' }), { plugins: [...plugins, ...(enableRollupLinker ? [angularRollupLinker(process.cwd())] : []), renderChunkLinker, postLinker], // Always alias fesm2022 deep imports to package root so vendor bridge can externalize properly resolve: { - alias: [ - // Map Angular deep ESM paths to bare package ids - { find: /^@angular\/([^/]+)\/fesm2022\/.*\.mjs$/, replacement: '@angular/$1' }, - { find: /^@nativescript\/angular\/fesm2022\/.*\.mjs$/, replacement: '@nativescript/angular' }, - // Prefer modern RxJS builds; avoid esm5 which explodes module count and memory - { find: /^rxjs\/dist\/esm5\/(.*)$/, replacement: 'rxjs/dist/esm2015/$1' }, - { find: /^rxjs\/operators$/, replacement: 'rxjs/dist/esm2015/operators/index.js' }, - { find: /^rxjs$/, replacement: 'rxjs/dist/esm2015/index.js' }, - // Existing optional animations shims - ], + alias: resolveAlias, }, - ...(disableAnimations && { - resolve: { - alias: [ - { - find: /^@angular\/animations(\/.+)?$/, // match subpaths too - replacement: new URL('../shims/angular-animations-stub.js', import.meta.url).pathname, - }, - { - find: /^@angular\/platform-browser\/animations(\/.+)?$/, - replacement: new URL('../shims/angular-animations-stub.js', import.meta.url).pathname, - }, - ], - }, - }), // Disable dependency optimization entirely for NativeScript Angular HMR. // Vite 5.1+: use noDiscovery with an empty include list (disabled was removed). // The HTTP loader + vendor bridge manage dependencies; pre-bundling can OOM. diff --git a/packages/vite/configuration/base.ts b/packages/vite/configuration/base.ts index 54f1f8d6d1..2b0761d374 100644 --- a/packages/vite/configuration/base.ts +++ b/packages/vite/configuration/base.ts @@ -309,7 +309,10 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }): }), // Ensure explicit keep markers are honored preserveImportsPlugin(), - hmrActive + // Disable vendor manifest plugin for Angular to avoid duplicate identifier issues + // when the Angular linker generates conflicting temporary variables (_c2, etc.) + // in the esbuild-bundled vendor output. + hmrActive && flavor !== 'angular' ? vendorManifestPlugin({ projectRoot, platform, @@ -465,7 +468,9 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }): // In HMR, avoid emitting a Rollup vendor chunk on disk. The dev server // serves vendor over HTTP and we separately emit a single ns-vendor.mjs // for Android SBG to scan via the vendorManifestPlugin above. - if (hmrActive) { + // Also disable chunking in dev mode to avoid circular dependency issues + // with Angular decorators during incremental rebuilds. + if (hmrActive || isDevMode) { return undefined; } const normalizedId = id.split('?')[0].replace(/\\/g, '/'); @@ -475,7 +480,8 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }): } if (id.includes('node_modules')) { // Keep common dependencies in the main bundle - if (id.includes('@angular/') || id.includes('@nativescript/angular') || id.includes('@nativescript/core')) { + // tslib must stay with Angular to avoid circular dependency issues with decorators + if (id.includes('@angular/') || id.includes('@nativescript/angular') || id.includes('@nativescript/core') || id.includes('/tslib/')) { return undefined; // Keep in main bundle } return 'vendor'; diff --git a/packages/vite/helpers/angular/angular-linker.ts b/packages/vite/helpers/angular/angular-linker.ts index e535e90b66..77b3a385cc 100644 --- a/packages/vite/helpers/angular/angular-linker.ts +++ b/packages/vite/helpers/angular/angular-linker.ts @@ -148,8 +148,9 @@ export function angularLinkerVitePluginPost(projectRoot?: string): Plugin { enforce: 'post', async transform(code, id) { const debug = process.env.VITE_DEBUG_LOGS === 'true' || process.env.VITE_DEBUG_LOGS === '1'; - // Only JS/ESM files - if (!/\.(m?js)(\?|$)/.test(id)) return null; + // JS/ESM files and TypeScript files (after Angular transform, TS files contain JS with partial declarations) + // Also match virtual module IDs that may not have extensions + if (!/\.(m?js|ts|tsx)(\?|$)/.test(id) && !id.startsWith('\0')) return null; if (!code || !containsRealNgDeclare(code)) return null; const { babel, linkerPlugin } = await ensureSharedAngularLinker(projectRoot); if (!babel || !linkerPlugin) return null; diff --git a/packages/vite/helpers/angular/shared-linker.ts b/packages/vite/helpers/angular/shared-linker.ts index c4419e769d..f1829e4bc3 100644 --- a/packages/vite/helpers/angular/shared-linker.ts +++ b/packages/vite/helpers/angular/shared-linker.ts @@ -10,33 +10,31 @@ export async function ensureSharedAngularLinker(projectRoot?: string) { const req = createRequire(projectRoot ? projectRoot + '/package.json' : import.meta.url); let localBabel: typeof import('@babel/core') | null = null; - let createLinker: any = null; + let linkerPlugin: any = null; try { const babelPath = req.resolve('@babel/core'); const linkerPath = req.resolve('@angular/compiler-cli/linker/babel'); localBabel = (await import(babelPath)) as any; const linkerMod: any = await import(linkerPath); - createLinker = linkerMod.createLinkerPlugin || linkerMod.createEs2015LinkerPlugin || null; + // Use the default linker plugin which includes fileSystem and logger + linkerPlugin = linkerMod.default; } catch { try { localBabel = (await import('@babel/core')) as any; } catch {} try { const linkerMod: any = await import('@angular/compiler-cli/linker/babel'); - createLinker = linkerMod.createLinkerPlugin || linkerMod.createEs2015LinkerPlugin || null; + linkerPlugin = linkerMod.default; } catch {} } - if (!localBabel || !createLinker) { + if (!localBabel || !linkerPlugin) { return { babel: null as any, linkerPlugin: null as any }; } sharedBabel = localBabel; - sharedLinkerPlugin = createLinker({ - sourceMapping: false, - linkPartialDeclaration: true, - } as any); + sharedLinkerPlugin = linkerPlugin; return { babel: sharedBabel, linkerPlugin: sharedLinkerPlugin }; } diff --git a/packages/vite/helpers/commonjs-plugins.ts b/packages/vite/helpers/commonjs-plugins.ts index 6104dc277c..6e7ccda2bf 100644 --- a/packages/vite/helpers/commonjs-plugins.ts +++ b/packages/vite/helpers/commonjs-plugins.ts @@ -1,23 +1,138 @@ // Support various @nativescript/core transient commonjs dependency cases export const commonjsPlugins = [ - // Fix source-map-js subpath imports for css-tree compatibility + // Fully virtualize source-map-js to avoid commonjs transform errors and ESM compatibility issues + // The module has complex internal structure that causes issues with both commonjs plugin transform + // and direct inclusion in ESM bundles { - name: 'source-map-js-subpath-compat', + name: 'source-map-js-esm-compat', enforce: 'pre', - resolveId(id) { + resolveId(id, importer, options) { + // Handle the main source-map-js import + if (id === 'source-map-js') { + return '\0source-map-js-virtual'; + } + // Handle subpath imports like source-map-js/lib/source-map-generator.js if (id === 'source-map-js/lib/source-map-generator.js') { return '\0source-map-generator-virtual'; } + // Handle the source-map.js entry point directly + if (id.endsWith('source-map-js/source-map.js')) { + return '\0source-map-js-virtual'; + } return null; }, load(id) { + if (id === '\0source-map-js-virtual') { + // Provide stub implementations for source-map-js + // These are used by css-tree for source map generation which is not needed at runtime + return ` +// Stub implementation of source-map-js for NativeScript runtime +// Source maps are handled at build time, not runtime + +export class SourceMapGenerator { + constructor(options) { + this._file = options?.file; + this._sourceRoot = options?.sourceRoot; + this._sources = []; + this._names = []; + this._mappings = []; + } + addMapping(mapping) {} + setSourceContent(sourceFile, content) {} + toJSON() { + return { + version: 3, + file: this._file || '', + sourceRoot: this._sourceRoot || '', + sources: this._sources, + names: this._names, + mappings: '' + }; + } + toString() { + return JSON.stringify(this.toJSON()); + } +} + +export class SourceMapConsumer { + constructor(rawSourceMap) {} + static fromSourceMap(generator) { return new SourceMapConsumer({}); } + originalPositionFor(args) { return { source: null, line: null, column: null, name: null }; } + generatedPositionFor(args) { return { line: null, column: null, lastColumn: null }; } + eachMapping(callback) {} + destroy() {} +} + +export class SourceNode { + constructor(line, column, source, chunk, name) { + this.children = []; + this.line = line; + this.column = column; + this.source = source; + this.name = name; + if (chunk) this.add(chunk); + } + add(chunk) { + if (Array.isArray(chunk)) { + chunk.forEach(c => this.add(c)); + } else if (chunk) { + this.children.push(chunk); + } + return this; + } + prepend(chunk) { + if (Array.isArray(chunk)) { + this.children = chunk.concat(this.children); + } else if (chunk) { + this.children.unshift(chunk); + } + return this; + } + walk(fn) {} + join(sep) { return this; } + replaceRight(pattern, replacement) { return this; } + setSourceContent(sourceFile, content) {} + walkSourceContents(fn) {} + toString() { + return this.children.map(c => typeof c === 'string' ? c : c.toString()).join(''); + } + toStringWithSourceMap(args) { + return { code: this.toString(), map: new SourceMapGenerator(args) }; + } +} + +export default { SourceMapGenerator, SourceMapConsumer, SourceNode }; +`; + } if (id === '\0source-map-generator-virtual') { - // Import the main source-map-js package and extract SourceMapGenerator + // Inline the SourceMapGenerator class directly to avoid virtual-to-virtual import issues return ` -import * as sourceMapJs from 'source-map-js'; -const { SourceMapGenerator } = sourceMapJs; -export { SourceMapGenerator }; +// Stub implementation of SourceMapGenerator for NativeScript runtime +export class SourceMapGenerator { + constructor(options) { + this._file = options?.file; + this._sourceRoot = options?.sourceRoot; + this._sources = []; + this._names = []; + this._mappings = []; + } + addMapping(mapping) {} + setSourceContent(sourceFile, content) {} + toJSON() { + return { + version: 3, + file: this._file || '', + sourceRoot: this._sourceRoot || '', + sources: this._sources, + names: this._names, + mappings: '' + }; + } + toString() { + return JSON.stringify(this.toJSON()); + } +} `; } return null; diff --git a/packages/vite/helpers/main-entry.ts b/packages/vite/helpers/main-entry.ts index d666ff5b69..75d94e7c7d 100644 --- a/packages/vite/helpers/main-entry.ts +++ b/packages/vite/helpers/main-entry.ts @@ -77,15 +77,44 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos' imports += `globalThis.__VISIONOS__ = ${opts.platform === 'visionos' ? 'true' : 'false'};\n`; imports += `globalThis.__APPLE__ = ${opts.platform === 'ios' || opts.platform === 'visionos' ? 'true' : 'false'};\n`; // ---- Vendor manifest bootstrap ---- - // Use single self-contained vendor module to avoid extra imports affecting chunking - imports += "import vendorManifest, { __nsVendorModuleMap } from '@nativescript/vendor';\n"; - imports += "import { installVendorBootstrap } from '@nativescript/vite/hmr/shared/runtime/vendor-bootstrap.js';\n"; - if (opts.verbose) { - imports += `console.info('[ns-entry] vendor manifest imported', { keys: Object.keys(vendorManifest||{}).length, hasMap: typeof __nsVendorModuleMap === 'object' });\n`; - } - imports += 'installVendorBootstrap(vendorManifest, __nsVendorModuleMap, __nsVerboseLog);\n'; - if (opts.verbose) { - imports += `console.info('[ns-entry] vendor bootstrap installed');\n`; + // For Angular, skip the full vendor manifest plugin to avoid duplicate identifier issues + // with the esbuild-bundled vendor output, but still register @nativescript/core directly. + if (flavor === 'angular') { + // Angular-specific: register all core Angular/NativeScript modules in vendor registry + // for robust HMR dependency resolution + imports += "import * as __nsCore from '@nativescript/core';\n"; + imports += "import * as __nsAngular from '@nativescript/angular';\n"; + imports += "import * as __angularCore from '@angular/core';\n"; + imports += "import * as __angularCommon from '@angular/common';\n"; + imports += `(function __nsRegisterCoreForAngular() { + try { + const g = globalThis; + const registry = g.__nsVendorRegistry || (g.__nsVendorRegistry = new Map()); + // Register @nativescript/core + registry.set('@nativescript/core', __nsCore); + g.__nativescriptCore = __nsCore; + // Register @nativescript/angular + registry.set('@nativescript/angular', __nsAngular); + g.__nativescriptAngular = __nsAngular; + // Register @angular/core + registry.set('@angular/core', __angularCore); + g.__angularCore = __angularCore; + // Register @angular/common + registry.set('@angular/common', __angularCommon); + ${opts.verbose ? "console.info('[ns-entry] Angular core modules exposed on globalThis for HMR');" : ''} + } catch (e) { try { console.error('[ns-entry] failed to register core for Angular', e); } catch {} } +})();\n`; + } else { + // Non-Angular: use full vendor manifest bootstrap + imports += "import vendorManifest, { __nsVendorModuleMap } from '@nativescript/vendor';\n"; + imports += "import { installVendorBootstrap } from '@nativescript/vite/hmr/shared/runtime/vendor-bootstrap.js';\n"; + if (opts.verbose) { + imports += `console.info('[ns-entry] vendor manifest imported', { keys: Object.keys(vendorManifest||{}).length, hasMap: typeof __nsVendorModuleMap === 'object' });\n`; + } + imports += 'installVendorBootstrap(vendorManifest, __nsVendorModuleMap, __nsVerboseLog);\n'; + if (opts.verbose) { + imports += `console.info('[ns-entry] vendor bootstrap installed');\n`; + } } } @@ -222,7 +251,11 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos' } // ---- Application main entry ---- - if (opts.hmrActive) { + // For Angular, skip HTTP-only boot because Angular modules can't be served via HTTP + // without vendor manifest (which causes duplicate identifier issues with esbuild). + // Angular HMR will use file watching + full rebuild instead of HTTP module loading. + const useHttpBoot = opts.hmrActive && flavor !== 'angular'; + if (useHttpBoot) { // HTTP-only dev boot: try to import the entire app over HTTP; if not reachable, keep retrying. if (opts.verbose) { imports += `console.info('[ns-entry] including HTTP-only boot', { platform: ${JSON.stringify(opts.platform)}, mainRel: ${JSON.stringify(mainEntryRelPosix)} });\n`; diff --git a/packages/vite/hmr/client/index.ts b/packages/vite/hmr/client/index.ts index fced6c15fa..60322df798 100644 --- a/packages/vite/hmr/client/index.ts +++ b/packages/vite/hmr/client/index.ts @@ -6,7 +6,7 @@ * The HMR client is evaluated via HTTP ESM on device; static imports would create secondary instances. */ -import { setHMRWsUrl, getHMRWsUrl, pendingModuleFetches, deriveHttpOrigin, setHttpOriginForVite, moduleFetchCache, requestModuleFromServer, getHttpOriginForVite, normalizeSpec, hmrMetrics, graph, setGraphVersion, getGraphVersion, getCurrentApp, getRootFrame, setCurrentApp, setRootFrame, getCore } from './utils.js'; +import { setHMRWsUrl, getHMRWsUrl, pendingModuleFetches, deriveHttpOrigin, setHttpOriginForVite, moduleFetchCache, requestModuleFromServer, getHttpOriginForVite, normalizeSpec, hmrMetrics, graph, setGraphVersion, getGraphVersion, getCurrentApp, getRootFrame, setCurrentApp, setRootFrame, getCore, requireModule } from './utils.js'; import { handleCssUpdates } from './css-handler.js'; // satisfied by define replacement @@ -50,7 +50,8 @@ ensureCoreAliasesOnGlobalThis(); * Flavor hooks */ import { installNsVueDevShims, ensureBackWrapperInstalled, getRootForVue, loadSfcComponent, ensureVueGlobals, ensurePiniaOnApp, addSfcMapping, recordVuePayloadChanges, handleVueSfcRegistry, handleVueSfcRegistryUpdate } from '../frameworks/vue/client/index.js'; -import { handleAngularHotUpdateMessage, installAngularHmrClientHooks } from '../frameworks/angular/client/index.js'; +import { handleAngularHotUpdateMessage, handleAngularHmrCodeMessage, installAngularHmrClientHooks } from '../frameworks/angular/client/index.js'; +import { handleAngularHmrV2, isAngularHmrV2Message } from '../frameworks/angular/client/hmr-v2.js'; switch (__NS_TARGET_FLAVOR__) { case 'vue': installNsVueDevShims(); @@ -562,7 +563,11 @@ try { async function processQueue(): Promise { if (!(globalThis as any).__NS_HMR_BOOT_COMPLETE__) { - if (VERBOSE) console.log('[hmr][gate] deferring HMR eval until boot complete'); + // Only log once per batch to avoid spam + if (VERBOSE && !(globalThis as any).__NS_HMR_GATE_LOGGED__) { + console.log('[hmr][gate] waiting for boot complete...'); + (globalThis as any).__NS_HMR_GATE_LOGGED__ = true; + } setTimeout(() => { try { processQueue(); @@ -570,6 +575,8 @@ async function processQueue(): Promise { }, 150); return Promise.resolve(); } + // Clear the gate log flag when boot completes + (globalThis as any).__NS_HMR_GATE_LOGGED__ = false; if (processingQueue) return processingPromise || Promise.resolve(); processingQueue = true; processingPromise = (async () => { @@ -882,7 +889,29 @@ async function handleHmrMessage(ev: any) { const deltaIds = Array.isArray(msg.changed) ? msg.changed.map((c: any) => c?.id).filter(Boolean) : []; notifyAppHmrUpdate('delta', deltaIds); return; - } else if (handleAngularHotUpdateMessage(msg, { getCore, verbose: VERBOSE })) { + } else if (isAngularHmrV2Message(msg)) { + // Angular component-level HMR v2 with pre-analyzed payload + try { + const success = await handleAngularHmrV2(msg, { getCore: requireModule, verbose: VERBOSE }); + if (success) { + if (VERBOSE) { + console.log('[hmr-client] Angular HMR v2 completed successfully'); + } + } else { + if (VERBOSE) { + console.warn('[hmr-client] Angular HMR v2 returned false, may need fallback'); + } + } + } catch (error) { + if (VERBOSE) { + console.error('[hmr-client] Angular HMR v2 error:', error); + } + } + return; + } else if (handleAngularHmrCodeMessage(msg, { getCore: requireModule, verbose: VERBOSE })) { + // Angular component-level HMR with ɵɵreplaceMetadata code (legacy v1) + return; + } else if (handleAngularHotUpdateMessage(msg, { getCore: requireModule, verbose: VERBOSE })) { return; } } @@ -919,6 +948,27 @@ async function handleHmrMessage(ev: any) { return; } } + // Handle custom HMR events (e.g., angular:component-update) + // These are dispatched to listeners registered via import.meta.hot.on() + if (msg.type === 'ns:hot-event' && typeof msg.event === 'string') { + try { + const dispatchFn = (globalThis as any).__NS_DISPATCH_HOT_EVENT__; + if (typeof dispatchFn === 'function') { + const success = dispatchFn(msg.event, msg.data); + if (VERBOSE) { + console.log('[hmr-client] Dispatched hot event:', msg.event, 'success:', success); + } + } else if (VERBOSE) { + console.warn('[hmr-client] __NS_DISPATCH_HOT_EVENT__ not available'); + } + return; + } catch (e) { + if (VERBOSE) { + console.warn('[hmr-client] Hot event dispatch failed:', e); + } + return; + } + } if (msg.type === 'ns:vue-sfc-registry') { handleVueSfcRegistry(msg); return; diff --git a/packages/vite/hmr/client/utils.ts b/packages/vite/hmr/client/utils.ts index 061ccd7b4e..d5fe129fc3 100644 --- a/packages/vite/hmr/client/utils.ts +++ b/packages/vite/hmr/client/utils.ts @@ -82,6 +82,74 @@ export function getCore(name: 'Application' | 'Frame' | 'Page' | 'Label'): any { return undefined; } +/** + * Universal module resolver for HMR. + * Resolves any module by its specifier (e.g., '@angular/core', '@nativescript/angular'). + * This is THE canonical way to access any dependency during HMR. + */ +export function requireModule(specifier: string): any { + const g: any = globalThis; + + // 1) Vendor registry - the primary source of truth for all npm dependencies + try { + const reg: Map | undefined = g.__nsVendorRegistry; + if (reg && typeof reg.get === 'function') { + const mod = reg.get(specifier); + if (mod) return mod; + } + } catch {} + + // 2) Try __nsVendorRequire / __nsRequire - synchronous module resolution + try { + const req = g.__nsVendorRequire || g.__nsRequire || g.require; + if (typeof req === 'function') { + try { + const mod = req(specifier); + if (mod) return mod; + } catch {} + } + } catch {} + + // 3) Try __nativeRequire for native module resolution + try { + const nativeReq = g.__nativeRequire; + if (typeof nativeReq === 'function') { + try { + const mod = nativeReq(specifier, '/'); + if (mod) return mod; + } catch {} + } + } catch {} + + return undefined; +} + +/** + * Get a specific export from a module. + * @param specifier Module path (e.g., '@angular/core') + * @param exportName The export to get (e.g., 'Component') + * @returns The export value or undefined + */ +export function getModuleExport(specifier: string, exportName: string): any { + const mod = requireModule(specifier); + if (!mod) return undefined; + + // Try direct access + if (mod[exportName] !== undefined) return mod[exportName]; + + // Try .default wrapper + if (mod.default && mod.default[exportName] !== undefined) { + return mod.default[exportName]; + } + + return undefined; +} + +// Resolve Angular/NativeScript Angular modules for HMR - DEPRECATED, use requireModule +export function getAngularModule(modulePath: string): any { + return requireModule(modulePath); +} + // last mounted app instance let CURRENT_APP: any | null = null; export function setCurrentApp(app: any | null) { diff --git a/packages/vite/hmr/frameworks/angular/client/hmr-v2.ts b/packages/vite/hmr/frameworks/angular/client/hmr-v2.ts new file mode 100644 index 0000000000..8aee23c727 --- /dev/null +++ b/packages/vite/hmr/frameworks/angular/client/hmr-v2.ts @@ -0,0 +1,677 @@ +/** + * Angular HMR Client Handler for NativeScript (v2) + * + * This is a clean, deterministic implementation that handles Angular HMR + * updates sent from the NativeScript Vite dev server. + * + * Key principles: + * 1. No guessing - the server sends all metadata we need + * 2. Simple execution - just resolve dependencies and call the function + * 3. Clear error handling - fail fast with useful diagnostics + * + * The server transforms AnalogJS HMR code into a format with: + * - The update function as default export + * - Metadata about expected parameters (__nsHmrMetadata__) + * - Clean ES module syntax + */ + +import { getHttpOriginForVite, deriveHttpOrigin, getHMRWsUrl } from '../../../client/utils.js'; + +declare const __NS_ENV_VERBOSE__: boolean | undefined; + +/** Metadata sent from the server about the HMR update */ +interface AngularHmrMetadata { + componentName: string; + componentPath: string; + functionName: string; + parameters: string[]; + namespacesCount: number; + localDependencies: Array<{ + name: string; + sourceHint: '@angular/core' | '@nativescript/angular' | '@nativescript/core' | 'component' | 'unknown'; + }>; + timestamp: number; +} + +/** Message format for Angular HMR v2 */ +interface AngularHmrV2Message { + type: 'ns:angular-hmr-v2'; + code: string; + metadata: AngularHmrMetadata; +} + +/** Module resolver function type */ +type ModuleResolver = (specifier: string) => any; + +/** + * Resolve a module by its specifier. + * Uses the vendor registry and native require. + */ +function resolveModule(specifier: string): any { + const g: any = globalThis; + + // 1. Vendor registry (primary source) + try { + const reg: Map | undefined = g.__nsVendorRegistry; + if (reg && typeof reg.get === 'function') { + const mod = reg.get(specifier); + if (mod) return mod; + } + } catch {} + + // 2. Native require + try { + const req = g.__nsVendorRequire || g.__nsRequire || g.require; + if (typeof req === 'function') { + const mod = req(specifier); + if (mod) return mod; + } + } catch {} + + // 3. Native module loader + try { + const nativeReq = g.__nativeRequire; + if (typeof nativeReq === 'function') { + const mod = nativeReq(specifier, '/'); + if (mod) return mod; + } + } catch {} + + return undefined; +} + +/** + * Build the namespaces array for the HMR function. + * + * The Angular compiler generates code that accesses namespaces by index: + * - ɵɵnamespaces[0] = @angular/core (always first) + * - ɵɵnamespaces[1] = @angular/common (if used) + * - etc. + * + * CRITICAL: We MUST use the ORIGINAL @angular/core module, not a fresh import! + * The original module has the TRACKED_LVIEWS map with all registered LViews. + * A fresh import would have an empty map and ɵɵreplaceMetadata wouldn't find any views. + * + * @param count Number of namespaces expected + * @returns Array of resolved namespace modules + */ +function buildNamespacesArray(count: number): any[] { + const g: any = globalThis; + + // CRITICAL: Use the original Angular core stored during app bootstrap + // This is set by @nativescript/angular's application.ts + const originalAngularCore = g.__NS_ANGULAR_CORE__; + + // Standard namespace order based on Angular compiler behavior + const namespaceModules = ['@angular/core', '@angular/common', '@angular/router', '@angular/forms', '@nativescript/angular']; + + const result: any[] = []; + for (let i = 0; i < count; i++) { + if (i === 0) { + // Always use original Angular core for first namespace + if (originalAngularCore) { + result.push(originalAngularCore); + if (typeof __NS_ENV_VERBOSE__ !== 'undefined' && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular-v2] Using ORIGINAL @angular/core for namespace[0]'); + } + } else { + // Fallback to resolved module if original not available + const mod = resolveModule('@angular/core'); + result.push(mod || {}); + if (typeof __NS_ENV_VERBOSE__ !== 'undefined' && __NS_ENV_VERBOSE__) { + console.warn('[hmr-angular-v2] WARNING: Original @angular/core not found, using fallback'); + } + } + } else if (i < namespaceModules.length) { + const mod = resolveModule(namespaceModules[i]); + result.push(mod || {}); + } else { + result.push({}); + } + } + + return result; +} + +/** + * Find the component class from available registries. + */ +function findComponentClass(name: string): any { + const g: any = globalThis; + + // 1. Angular components registry + if (g.__NS_ANGULAR_COMPONENTS__ && g.__NS_ANGULAR_COMPONENTS__[name]) { + return g.__NS_ANGULAR_COMPONENTS__[name]; + } + + // 2. HMR module registry + if (g.__NS_HMR_MODULE_REGISTRY__) { + for (const mod of Object.values(g.__NS_HMR_MODULE_REGISTRY__)) { + if (mod && typeof mod === 'object') { + if ((mod as any)[name]) return (mod as any)[name]; + if ((mod as any).default && (mod as any).default[name]) { + return (mod as any).default[name]; + } + } + } + } + + // 3. Vendor registry - check all modules + try { + const reg: Map | undefined = g.__nsVendorRegistry; + if (reg && typeof reg.forEach === 'function') { + let found: any = undefined; + reg.forEach((mod) => { + if (!found && mod && typeof mod === 'object') { + if (mod[name]) found = mod[name]; + else if (mod.default && mod.default[name]) found = mod.default[name]; + } + }); + if (found) return found; + } + } catch {} + + return undefined; +} + +/** + * Resolve a local dependency by name. + */ +function resolveLocalDependency(dep: { name: string; sourceHint: string }): any { + // Try the hinted source first + if (dep.sourceHint !== 'unknown' && dep.sourceHint !== 'component') { + const mod = resolveModule(dep.sourceHint); + if (mod && mod[dep.name] !== undefined) { + return mod[dep.name]; + } + } + + // Try all known sources + const sources = ['@angular/core', '@nativescript/angular', '@nativescript/core', '@angular/common', '@angular/forms', '@angular/router']; + + for (const source of sources) { + const mod = resolveModule(source); + if (mod && mod[dep.name] !== undefined) { + return mod[dep.name]; + } + } + + return undefined; +} + +/** + * Force a view refresh after HMR update. + * Since Angular's internal LView tracking isn't exposed, we need to manually + * trigger a view recreation to apply template changes. + */ +async function forceViewRefresh(componentName: string, componentClass: any, verbose: boolean): Promise { + const g: any = globalThis; + + try { + // Strategy 1: Use NativeScript's Frame navigation to refresh + const Frame = g.Frame || g.require?.('@nativescript/core')?.Frame; + if (Frame) { + const topFrame = Frame.topmost?.(); + if (topFrame && topFrame.currentPage) { + // Navigate to same page with clearHistory to force refresh + const currentEntry = topFrame.currentEntry; + if (currentEntry) { + if (verbose) { + console.log('[hmr-angular-v2] Attempting frame refresh via navigate'); + } + + // Use backstackVisible: false and clearHistory to replace current page + try { + // Get the module path or component from the entry + const moduleName = currentEntry.moduleName; + const create = currentEntry.create; + + if (moduleName || create) { + topFrame.navigate({ + ...currentEntry, + clearHistory: true, + backstackVisible: false, + animated: false, + transition: { name: 'none', duration: 0 }, + }); + + if (verbose) { + console.log('[hmr-angular-v2] Navigation triggered for refresh'); + } + return true; + } + } catch (navErr) { + if (verbose) { + console.warn('[hmr-angular-v2] Navigation refresh failed:', navErr); + } + } + } + } + } + + // Strategy 2: Try router navigation (back and forward to same route) + const router = g.__NS_ANGULAR_ROUTER__; + if (router && typeof router.navigate === 'function') { + const currentUrl = router.url; + if (currentUrl) { + if (verbose) { + console.log('[hmr-angular-v2] Attempting router refresh to:', currentUrl); + } + + // Navigate to same URL with skipLocationChange to avoid history issues + try { + await router.navigateByUrl(currentUrl, { skipLocationChange: true, replaceUrl: true }); + if (verbose) { + console.log('[hmr-angular-v2] Router navigation completed'); + } + return true; + } catch (routerErr) { + if (verbose) { + console.warn('[hmr-angular-v2] Router refresh failed:', routerErr); + } + } + } + } + + // Strategy 3: Force page-router-outlet refresh via NativeScript Angular + const nsAngular = resolveModule('@nativescript/angular'); + if (nsAngular) { + // Try to find and refresh the page-router-outlet + const rootView = g.Application?.getRootView?.(); + if (rootView) { + // Walk the view tree to find and refresh Angular components + try { + refreshAngularViews(rootView, componentClass, verbose); + return true; + } catch (walkErr) { + if (verbose) { + console.warn('[hmr-angular-v2] View tree refresh failed:', walkErr); + } + } + } + } + + return false; + } catch (error) { + if (verbose) { + console.error('[hmr-angular-v2] forceViewRefresh error:', error); + } + return false; + } +} + +/** + * Walk the NativeScript view tree and try to refresh Angular component views. + */ +function refreshAngularViews(view: any, componentClass: any, verbose: boolean): void { + if (!view) return; + + // Check if this view is associated with an Angular component + const ngComponent = view.__ng_component__ || view.ngComponent || (view as any)._ngComponent; + if (ngComponent && ngComponent.constructor === componentClass) { + if (verbose) { + console.log('[hmr-angular-v2] Found matching component instance, attempting refresh'); + } + + // Try to get the change detector and force refresh + const injector = ngComponent.__injector__ || (ngComponent as any).injector; + if (injector) { + try { + const cdRef = injector.get?.(Symbol.for('ChangeDetectorRef')); + if (cdRef) { + cdRef.markForCheck(); + cdRef.detectChanges(); + } + } catch {} + } + } + + // Recursively process children + if (view.eachChildView) { + view.eachChildView((child: any) => { + refreshAngularViews(child, componentClass, verbose); + return true; + }); + } +} + +/** + * Execute the HMR update and refresh the view. + * + * The HMR update function calls ɵɵreplaceMetadata which: + * 1. Updates the template function (good) + * 2. Clears tView to null (problematic for re-bootstrap) + * 3. Preserves directiveDefs from old definition + * + * Since NativeScript doesn't have TRACKED_LVIEWS for in-place view recreation, + * we need to re-bootstrap. But re-bootstrap needs a valid tView. + * + * Solution: After the update, restore critical properties that re-bootstrap needs, + * while keeping the new template. The new tView will be created during bootstrap. + */ +async function executeHmrUpdate(updateFn: Function, componentClass: any, namespaces: any[], locals: any[], metadata: AngularHmrMetadata, verbose: boolean): Promise { + const g: any = globalThis; + + try { + const angularCore = namespaces[0]; + const getComponentDef = angularCore?.ɵgetComponentDef || angularCore?.['ɵgetComponentDef']; + + // Get the component definition before update + let defBefore: any = null; + let savedTView: any = null; + let savedDirectiveDefs: any = null; + + if (getComponentDef && componentClass) { + defBefore = getComponentDef(componentClass); + // Save properties that ɵɵreplaceMetadata will corrupt + savedTView = defBefore?.tView; + savedDirectiveDefs = defBefore?.directiveDefs; + + if (verbose) { + console.log('[hmr-angular-v2] Component def BEFORE:', { + id: defBefore?.id, + type: defBefore?.type?.name, + hasTView: !!defBefore?.tView, + templateFn: defBefore?.template?.name || typeof defBefore?.template, + }); + } + } + + if (verbose && angularCore) { + console.log('[hmr-angular-v2] ɵɵreplaceMetadata available:', typeof angularCore.ɵɵreplaceMetadata === 'function'); + } + + // Call the update function with resolved arguments + // This calls ɵɵreplaceMetadata internally which updates the template + if (verbose) { + console.log('[hmr-angular-v2] Calling update function with:', { + componentClass: componentClass?.name, + namespacesCount: namespaces.length, + localsCount: locals.length, + }); + } + + updateFn(componentClass, namespaces, ...locals); + + if (verbose) { + console.log('[hmr-angular-v2] Update function executed successfully'); + } + + // Get the definition after update to capture the new template + let defAfter: any = null; + let newTemplate: any = null; + + if (getComponentDef && componentClass) { + defAfter = getComponentDef(componentClass); + newTemplate = defAfter?.template; + + if (verbose) { + console.log('[hmr-angular-v2] Component def AFTER (before fix):', { + id: defAfter?.id, + type: defAfter?.type?.name, + hasTView: !!defAfter?.tView, + templateFn: defAfter?.template?.name || typeof defAfter?.template, + templateChanged: defBefore?.template !== defAfter?.template, + }); + } + + // CRITICAL FIX: ɵɵreplaceMetadata clears tView which breaks re-bootstrap. + // We need to keep the new template but restore tView so the component + // can be properly instantiated during re-bootstrap. + // + // The tView will be recreated with the new template when the component + // is first instantiated after re-bootstrap. + if (defAfter && savedTView && !defAfter.tView) { + // Instead of restoring the old tView (which has old template refs), + // we leave tView as null. Angular will create a fresh tView on + // first component instantiation using the new template. + // + // BUT: We need to ensure directiveDefs is still valid for the + // initial setup. The issue is directiveDefs may contain stale refs. + + if (verbose) { + console.log('[hmr-angular-v2] tView was cleared by ɵɵreplaceMetadata (expected)'); + console.log('[hmr-angular-v2] directiveDefs preserved:', !!defAfter.directiveDefs); + } + } + } + + // Skip Angular change detection - we're going to re-bootstrap anyway + // and the old app instance will be destroyed + + // Re-bootstrap the Angular app + // The component class now has the updated template function. + // A fresh tView will be created during bootstrap. + if (verbose) { + console.log('[hmr-angular-v2] Triggering re-bootstrap to apply new template...'); + } + + // IMPORTANT: We need to clear directiveDefs to force Angular to + // re-resolve them during the new bootstrap. The old directiveDefs + // may contain references to component types that are now stale. + if (defAfter) { + // Reset directiveDefs to null - Angular will re-resolve them + // from the component's imports during the next bootstrap + defAfter.directiveDefs = null; + defAfter.pipeDefs = null; + + if (verbose) { + console.log('[hmr-angular-v2] Cleared directiveDefs and pipeDefs for fresh resolution'); + } + } + + triggerRebootstrap(verbose); + + // Request native layout update + try { + const rootView = g.Application?.getRootView?.(); + if (rootView && typeof rootView.requestLayout === 'function') { + rootView.requestLayout(); + } + } catch {} + + return true; + } catch (error) { + console.error('[hmr-angular-v2] Update function failed:', error); + return false; + } +} + +/** + * Handle Angular HMR v2 message from the server. + * + * This is the main entry point for processing HMR updates. + * + * The approach: + * 1. Execute the HMR update function (from AnalogJS) to update the component's template + * 2. Re-bootstrap the Angular app to recreate views with the updated template + * + * The HMR code from AnalogJS calls ɵɵreplaceMetadata which updates the component + * definition in-place. Then re-bootstrap creates new component instances that + * use the updated definition. + */ +export async function handleAngularHmrV2(msg: AngularHmrV2Message, options: { getCore?: ModuleResolver; verbose?: boolean } = {}): Promise { + const g: any = globalThis; + const verbose = options.verbose ?? (typeof __NS_ENV_VERBOSE__ !== 'undefined' && __NS_ENV_VERBOSE__); + + if (!msg || msg.type !== 'ns:angular-hmr-v2') { + return false; + } + + const { code, metadata } = msg; + + if (!code || !metadata) { + if (verbose) { + console.warn('[hmr-angular-v2] Missing code or metadata'); + } + return false; + } + + if (verbose) { + console.log('[hmr-angular-v2] Processing HMR update for:', metadata.componentName); + console.log('[hmr-angular-v2] Component path:', metadata.componentPath); + console.log('[hmr-angular-v2] HMR code length:', code.length); + } + + try { + // Step 1: Execute the HMR code to get the update function + // We use Blob + URL.createObjectURL to execute the ES module code + let updateFn: Function | undefined; + + try { + const blob = new Blob([code], { type: 'application/javascript' }); + const blobUrl = URL.createObjectURL(blob); + + if (verbose) { + console.log('[hmr-angular-v2] Importing HMR module from blob...'); + } + + const hmrModule = await import(/* @vite-ignore */ blobUrl); + + // Revoke the blob URL after import + try { + URL.revokeObjectURL(blobUrl); + } catch {} + + // Get the update function + updateFn = hmrModule.default || hmrModule[metadata.functionName]; + + if (verbose) { + console.log('[hmr-angular-v2] HMR module loaded, exports:', Object.keys(hmrModule)); + console.log('[hmr-angular-v2] Update function:', typeof updateFn); + } + } catch (blobErr) { + if (verbose) { + console.error('[hmr-angular-v2] Failed to load HMR module via blob:', blobErr); + } + // Fallback: try direct eval (less safe but may work) + try { + const evalCode = code.replace(/export\s+default\s+function/, 'var __hmrUpdateFn__ = function'); + eval(evalCode); + updateFn = (globalThis as any).__hmrUpdateFn__; + delete (globalThis as any).__hmrUpdateFn__; + } catch (evalErr) { + if (verbose) { + console.error('[hmr-angular-v2] Eval fallback also failed:', evalErr); + } + } + } + + if (typeof updateFn !== 'function') { + if (verbose) { + console.warn('[hmr-angular-v2] No update function found, falling back to re-bootstrap only'); + } + triggerRebootstrap(verbose); + return true; + } + + // Step 2: Resolve dependencies for the update function + + // Find the component class + const componentClass = findComponentClass(metadata.componentName); + if (!componentClass) { + if (verbose) { + console.warn('[hmr-angular-v2] Component class not found:', metadata.componentName); + console.log('[hmr-angular-v2] Available in registry:', Object.keys(g.__NS_ANGULAR_COMPONENTS__ || {})); + } + triggerRebootstrap(verbose); + return true; + } + + if (verbose) { + console.log('[hmr-angular-v2] Found component class:', componentClass.name); + } + + // Build namespaces array + const namespaces = buildNamespacesArray(metadata.namespacesCount); + + if (verbose) { + console.log('[hmr-angular-v2] Built namespaces array, count:', namespaces.length); + console.log('[hmr-angular-v2] namespace[0] is original Angular core:', namespaces[0] === g.__NS_ANGULAR_CORE__); + } + + // Resolve local dependencies + const locals: any[] = []; + for (const dep of metadata.localDependencies) { + const resolved = resolveLocalDependency(dep); + if (resolved === undefined && verbose) { + console.warn('[hmr-angular-v2] Could not resolve local dependency:', dep.name); + } + locals.push(resolved); + } + + if (verbose) { + console.log( + '[hmr-angular-v2] Resolved locals:', + metadata.localDependencies.map((d) => d.name), + ); + } + + // Step 3: Execute the HMR update + const success = await executeHmrUpdate(updateFn, componentClass, namespaces, locals, metadata, verbose); + + if (verbose) { + console.log('[hmr-angular-v2] executeHmrUpdate returned:', success); + } + + return success; + } catch (error) { + if (verbose) { + console.error('[hmr-angular-v2] Error during HMR update:', error); + } + // Fall back to re-bootstrap + triggerRebootstrap(verbose); + return true; + } +} + +/** + * Trigger a full app re-bootstrap. + * This is the reliable way to handle Angular HMR - same as webpack HMR. + * Uses the __reboot_ng_modules__ function exposed by @nativescript/angular. + */ +function triggerRebootstrap(verbose: boolean): void { + const g: any = globalThis; + + if (verbose) { + console.log('[hmr-angular-v2] Triggering re-bootstrap via __reboot_ng_modules__...'); + } + + try { + // Primary method: Use the reboot function from @nativescript/angular + if (typeof g.__reboot_ng_modules__ === 'function') { + if (verbose) { + console.log('[hmr-angular-v2] Calling __reboot_ng_modules__()'); + } + g.__reboot_ng_modules__(false); // false = don't dispose platform + return; + } + + // Fallback: Try individual dispose and bootstrap + if (typeof g.__dispose_app_ng_modules__ === 'function' && typeof g.__bootstrap_app_ng_modules__ === 'function') { + if (verbose) { + console.log('[hmr-angular-v2] Calling __dispose_app_ng_modules__ + __bootstrap_app_ng_modules__'); + } + g.__dispose_app_ng_modules__(); + g.__bootstrap_app_ng_modules__(); + return; + } + + // Last resort: Just tick the app ref + if (verbose) { + console.warn('[hmr-angular-v2] No reboot function found, falling back to tick()'); + } + if (g.__NS_ANGULAR_APP_REF__ && typeof g.__NS_ANGULAR_APP_REF__.tick === 'function') { + g.__NS_ANGULAR_APP_REF__.tick(); + } + } catch (error) { + console.error('[hmr-angular-v2] Re-bootstrap failed:', error); + } +} + +/** + * Check if a message is an Angular HMR v2 message. + */ +export function isAngularHmrV2Message(msg: any): msg is AngularHmrV2Message { + return msg && msg.type === 'ns:angular-hmr-v2'; +} diff --git a/packages/vite/hmr/frameworks/angular/client/index.ts b/packages/vite/hmr/frameworks/angular/client/index.ts index b470d74836..2c69aa0235 100644 --- a/packages/vite/hmr/frameworks/angular/client/index.ts +++ b/packages/vite/hmr/frameworks/angular/client/index.ts @@ -20,43 +20,1007 @@ interface AngularUpdateOptions { verbose: boolean; } -export function handleAngularHotUpdateMessage(msg: any, options: AngularUpdateOptions): boolean { - if (!msg || msg.type !== 'ns:angular-update') { +/** + * Rewrites AnalogJS HMR code to self-invoke the update function. + * + * The original HMR code structure: + * ``` + * import { SimpleTestComponent } from "..."; + * import * as ɵɵnamespaces from "@angular/core"; + * import { NativeScriptCommonModule } from "..."; + * import { Component } from "@angular/core"; + * export default function SimpleTestComponent_UpdateMetadata(SimpleTestComponent, ɵɵnamespaces, NativeScriptCommonModule, Component) { + * // ... uses ɵɵreplaceMetadata + * } + * ``` + * + * The function is exported but expects the imported values as parameters. + * We rewrite it to call the function at the end with the imported values: + * ``` + * import { SimpleTestComponent as _hmr_0 } from "..."; + * import * as _hmr_1 from "@angular/core"; + * import { NativeScriptCommonModule as _hmr_2 } from "..."; + * import { Component as _hmr_3 } from "@angular/core"; + * + * function SimpleTestComponent_UpdateMetadata(SimpleTestComponent, ɵɵnamespaces, NativeScriptCommonModule, Component) { + * // ... uses ɵɵreplaceMetadata + * } + * + * // Self-invoke with imported values + * export const __hmrResult__ = (() => { try { SimpleTestComponent_UpdateMetadata(_hmr_0, _hmr_1, _hmr_2, _hmr_3); return true; } catch (e) { return e; } })(); + * export const __hmrError__ = __hmrResult__ === true ? null : __hmrResult__; + * ``` + */ +function rewriteHmrCodeToSelfInvoke(code: string, options: AngularUpdateOptions): string { + try { + // Parse imports to collect all import information first + const importRegex = /import\s+(?:\*\s+as\s+(\w+)|{\s*([^}]+)\s*})\s+from\s+["']([^"']+)["'];?/g; + const imports: { name: string; alias: string; source: string; isNamespace: boolean; originalName: string }[] = []; + const importReplacements: { original: string; replacement: string }[] = []; + let importIndex = 0; + + let match; + while ((match = importRegex.exec(code)) !== null) { + const [fullMatch, namespaceImport, namedImports, source] = match; + + if (namespaceImport) { + // import * as name from 'source' + const alias = `_hmr_${importIndex++}`; + imports.push({ + name: namespaceImport, + alias, + source, + isNamespace: true, + originalName: namespaceImport, + }); + importReplacements.push({ + original: fullMatch, + replacement: `import * as ${alias} from "${source}";`, + }); + } else if (namedImports) { + // import { name } from 'source' or import { name as alias } from 'source' + // or import { name1, name2 } from 'source' + const parts = namedImports.split(',').map((p) => p.trim()); + const newImportParts: string[] = []; + for (const part of parts) { + const asMatch = part.match(/(\w+)\s+as\s+(\w+)/); + const name = asMatch ? asMatch[2] : part; + const originalName = asMatch ? asMatch[1] : part; + const alias = `_hmr_${importIndex++}`; + imports.push({ + name, + alias, + source, + isNamespace: false, + originalName, + }); + newImportParts.push(`${originalName} as ${alias}`); + } + importReplacements.push({ + original: fullMatch, + replacement: `import { ${newImportParts.join(', ')} } from "${source}";`, + }); + } + } + + if (imports.length === 0) { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] no imports found in HMR code, using as-is'); + } + return code; + } + + // Apply all import replacements + let modifiedCode = code; + for (const { original, replacement } of importReplacements) { + modifiedCode = modifiedCode.replace(original, replacement); + } + + // Extract the function name from "export default function FnName(...)" + const fnMatch = modifiedCode.match(/export\s+default\s+function\s+(\w+)\s*\(/); + if (!fnMatch) { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] no default function export found, using as-is'); + } + return code; + } + + const fnName = fnMatch[1]; + + // Remove "export default" from the function declaration + modifiedCode = modifiedCode.replace(/export\s+default\s+function/, 'function'); + + // Get function parameter names in order (they match import order in AnalogJS generated code) + const fnParamMatch = modifiedCode.match(new RegExp(`function\\s+${fnName}\\s*\\(([^)]*)\\)`)); + if (!fnParamMatch) { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] could not parse function parameters'); + } + return code; + } + + const paramNames = fnParamMatch[1] + .split(',') + .map((p) => p.trim()) + .filter((p) => p); + + // Build the argument list: match param names to import names + const args: string[] = []; + for (const param of paramNames) { + const importInfo = imports.find((i) => i.name === param); + if (importInfo) { + args.push(importInfo.alias); + } else { + // Parameter doesn't match an import - this shouldn't happen but handle it + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] param not found in imports:', param); + } + args.push('undefined'); + } + } + + // Append self-invocation at the end + const selfInvoke = ` +// HMR self-invocation +export const __hmrResult__ = (() => { try { ${fnName}(${args.join(', ')}); return true; } catch (e) { console.error('[hmr] update error:', e); return false; } })(); +export const __hmrError__ = __hmrResult__ === true ? null : __hmrResult__; +`; + + modifiedCode += selfInvoke; + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] rewritten HMR code:', modifiedCode.substring(0, 800)); + } + + return modifiedCode; + } catch (e) { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] failed to rewrite HMR code:', e); + } + // Return original code on error + return code; + } +} + +/** + * Handle Angular component-level HMR update with ɵɵreplaceMetadata code. + * This is called when AnalogJS generates HMR update code for a component. + * + * The HMR code is an ES module with this structure: + * ``` + * import { ... } from '@angular/core'; + * import { ComponentClass } from './component-path'; + * import { SomeModule } from 'some-module'; + * export default function ComponentClass_UpdateMetadata(ComponentClass, ɵɵnamespaces, SomeModule, Component) { + * // calls to ɵɵreplaceMetadata + * } + * ``` + * + * We use blob URLs with the runtime's Blob implementation: + * 1. Create a Blob from the HMR code + * 2. Create a blob URL via URL.createObjectURL() + * 3. Dynamic import the blob URL + * 4. The runtime reads the blob content via blob.text() and compiles it as a module + */ +export async function handleAngularHmrCodeMessage(msg: any, options: AngularUpdateOptions): Promise { + if (!msg || msg.type !== 'ns:angular-hmr-code') { return false; } + + const code = msg.code; + if (!code || typeof code !== 'string') { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.warn('[hmr-angular] received empty HMR code'); + } + return false; + } + + const g: any = globalThis; + try { - const g: any = globalThis; - const App = options.getCore('Application') || g.Application; - const bootstrap = g.__NS_ANGULAR_BOOTSTRAP__; - if (typeof App?.resetRootView === 'function' && typeof bootstrap === 'function') { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] executing HMR update code, length:', code.length); + console.log('[hmr-angular] code preview:', code.substring(0, 500)); + // Print more of the code to understand its structure + console.log('[hmr-angular] code middle:', code.substring(500, 1500)); + } + + // Rewrite the HMR code to self-invoke the update function + // The AnalogJS HMR code exports a function that takes the imported values as parameters, + // but we need to call it with those values. We'll append a self-call at the end. + const modifiedCode = rewriteHmrCodeToSelfInvoke(code, options); + + // Create a Blob from the HMR code and use dynamic import + // The runtime now has a proper Blob implementation with text() support + const blob = new Blob([modifiedCode], { type: 'application/javascript' }); + const blobUrl = URL.createObjectURL(blob); + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] created blob URL:', blobUrl); + } + + try { + // Dynamic import the blob URL - the runtime will call blob.text() to get the code + const module = await import(blobUrl); + if (options.verbose && __NS_ENV_VERBOSE__) { - console.log('[hmr-angular] resetting root view via captured bootstrap'); + console.log('[hmr-angular] HMR module imported successfully'); + console.log('[hmr-angular] module exports:', Object.keys(module)); + } + + // Check if the module self-invoked (has __hmrResult__) + if (module.__hmrResult__ === true) { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] HMR update executed successfully during module load'); + } + } else if (module.__hmrError__) { + if (options.verbose) { + console.warn('[hmr-angular] HMR update failed:', module.__hmrError__); + } + triggerRebootstrap(g, options); + return true; + } else if (typeof module.default === 'function') { + // The HMR code has no imports - it's a function that expects resolved arguments + // We need to call it manually with the resolved dependencies + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] HMR code has default function export, calling with resolved args'); + } + + const updateFn = module.default; + const fnName = updateFn.name || 'unknown'; + + // Parse the function signature to get parameter names + const fnStr = updateFn.toString(); + const paramMatch = fnStr.match(/^function\s*\w*\s*\(([^)]*)\)/); + const params = paramMatch + ? paramMatch[1] + .split(',') + .map((p: string) => p.trim()) + .filter(Boolean) + : []; + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] update function:', fnName); + console.log('[hmr-angular] parameters:', params); + } + + // Resolve the arguments + const args = resolveHmrFunctionArgs(params, code, options, g); + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] resolved args count:', args.length); + // Print detailed info about each argument + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const argType = typeof arg; + const argName = (arg && arg.name) || (arg && arg.constructor && arg.constructor.name) || argType; + console.log(`[hmr-angular] arg[${i}] type:`, argType, 'name:', argName); + if (i === 1 && Array.isArray(arg)) { + // ɵɵnamespaces array - check what's in it + console.log('[hmr-angular] ɵɵnamespaces length:', arg.length); + for (let j = 0; j < arg.length; j++) { + const ns = arg[j]; + console.log(`[hmr-angular] namespace[${j}] has ɵɵreplaceMetadata:`, !!(ns && ns.ɵɵreplaceMetadata)); + if (j === 0 && ns) { + // First namespace should be @angular/core + console.log('[hmr-angular] namespace[0].ɵɵreplaceMetadata type:', typeof ns.ɵɵreplaceMetadata); + console.log('[hmr-angular] namespace[0].ɵɵdefineComponent type:', typeof ns.ɵɵdefineComponent); + } + } + } + } + } + + // Capture the component's tView before the update + let tViewBefore: any = null; + const componentClass = args[0]; + if (componentClass) { + const compDefBefore = componentClass.ɵcmp || componentClass['ɵcmp']; + if (compDefBefore) { + tViewBefore = compDefBefore.tView; + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] BEFORE: component tView exists:', !!tViewBefore); + } + } + } + + // Instrument ɵɵreplaceMetadata to verify it's being called + const namespacesArg = args[1]; + let replaceMetadataCalled = false; + if (namespacesArg && Array.isArray(namespacesArg) && namespacesArg[0]) { + const angularCoreNs = namespacesArg[0]; + const originalReplaceMetadata = angularCoreNs.ɵɵreplaceMetadata; + if (originalReplaceMetadata && options.verbose && __NS_ENV_VERBOSE__) { + angularCoreNs.ɵɵreplaceMetadata = function (...replaceArgs: any[]) { + replaceMetadataCalled = true; + console.log('[hmr-angular] ɵɵreplaceMetadata CALLED!'); + console.log('[hmr-angular] replaceMetadata args count:', replaceArgs.length); + console.log('[hmr-angular] replaceMetadata type:', replaceArgs[0]?.name); + console.log('[hmr-angular] replaceMetadata applyMetadata:', typeof replaceArgs[1]); + console.log('[hmr-angular] replaceMetadata namespaces length:', replaceArgs[2]?.length); + console.log('[hmr-angular] replaceMetadata locals:', replaceArgs[3]); + try { + const result = originalReplaceMetadata.apply(this, replaceArgs); + console.log('[hmr-angular] ɵɵreplaceMetadata completed successfully'); + return result; + } catch (e) { + console.error('[hmr-angular] ɵɵreplaceMetadata threw error:', e); + throw e; + } + }; + } + } + + try { + updateFn(...args); + + // Check the component's tView after the update + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] HMR update function executed successfully'); + console.log('[hmr-angular] ɵɵreplaceMetadata was called:', replaceMetadataCalled); + + const compDefAfter = componentClass && (componentClass.ɵcmp || componentClass['ɵcmp']); + if (compDefAfter) { + const tViewAfter = compDefAfter.tView; + console.log('[hmr-angular] AFTER: component tView exists:', !!tViewAfter); + console.log('[hmr-angular] tView same object?:', tViewBefore === tViewAfter); + console.log('[hmr-angular] component def id:', compDefAfter.id); + } + + // Check if Angular's LViews are being tracked + const angularCore = options.getCore('@angular/core'); + if (angularCore) { + // Restore the original ɵɵreplaceMetadata if we wrapped it + if (namespacesArg && Array.isArray(namespacesArg) && namespacesArg[0]) { + const angularCoreNs = namespacesArg[0]; + const originalFn = options.getCore('@angular/core').ɵɵreplaceMetadata; + if (originalFn) { + angularCoreNs.ɵɵreplaceMetadata = originalFn; + } + } + + // Try to access tracked LViews - this is what ɵɵreplaceMetadata iterates + const getTrackedLViews = angularCore.ɵgetTrackedLViews || angularCore['ɵgetTrackedLViews']; + if (getTrackedLViews) { + const tracked = getTrackedLViews(); + console.log('[hmr-angular] tracked LViews:', tracked ? (tracked.size ?? 'Map exists') : 'null'); + } else { + console.log('[hmr-angular] ɵgetTrackedLViews not found in @angular/core'); + } + } + } + } catch (updateError) { + if (options.verbose) { + console.warn('[hmr-angular] HMR update function failed:', updateError); + } + triggerRebootstrap(g, options); + return true; + } } + + // Trigger change detection to update the view + const appRef = g.__NS_ANGULAR_APP_REF__; + if (appRef && typeof appRef.tick === 'function') { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] triggering change detection'); + } + + // For template changes, we need to do more than just tick() + // The ɵɵreplaceMetadata updates the component definition, but existing + // component instances still have the old compiled view. + // We need to trigger a view refresh or re-creation. + + // Try to get the component and refresh its view + try { + if (appRef.components && appRef.components.length > 0) { + for (const componentRef of appRef.components) { + // Mark the component for check to force re-render + if (componentRef.changeDetectorRef) { + componentRef.changeDetectorRef.markForCheck(); + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] marked component for check'); + } + } + + // Try to use the internal _view or hostView to force refresh + const hostView = (componentRef as any).hostView || (componentRef as any)._view; + if (hostView && typeof hostView.detectChanges === 'function') { + hostView.detectChanges(); + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] triggered detectChanges on hostView'); + } + } + } + } + } catch (e) { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] view refresh attempt:', e); + } + } + + // Run tick to flush any pending changes + appRef.tick(); + + // For NativeScript, we may need to force a native view update + try { + const rootView = g.Application?.getRootView?.(); + if (rootView && typeof rootView.requestLayout === 'function') { + rootView.requestLayout(); + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] requested layout on root view'); + } + } + } catch {} + } + + return true; + } catch (importError) { + if (options.verbose) { + console.warn('[hmr-angular] blob import failed:', importError); + } + // Clean up the blob URL try { - g.__NS_DEV_RESET_IN_PROGRESS__ = true; + URL.revokeObjectURL(blobUrl); } catch {} - App.resetRootView({ - create: () => { - try { - return bootstrap(); - } finally { - try { - g.__NS_DEV_RESET_IN_PROGRESS__ = false; - } catch {} - } - }, + // Fall back to manual parsing approach + return handleAngularHmrCodeManually(code, options, g); + } finally { + // Clean up the blob URL after import + try { + URL.revokeObjectURL(blobUrl); + } catch {} + } + } catch (error) { + if (options.verbose) { + try { + console.warn('[hmr-angular] failed to handle HMR code', (error && (error as any).message) || error); + } catch {} + } + triggerRebootstrap(g, options); + } + return true; +} + +/** + * Fallback: Manually parse and execute HMR code when blob import fails. + * This handles older runtimes or environments without blob URL support. + */ +function handleAngularHmrCodeManually(code: string, options: AngularUpdateOptions, g: any): boolean { + try { + // Extract the default export function signature to understand what arguments are needed + // Pattern: export default function ComponentName_UpdateMetadata(ComponentName, ɵɵnamespaces, ...modules) + const fnMatch = code.match(/export\s+default\s+function\s+(\w+)\s*\(([^)]*)\)/); + if (!fnMatch) { + if (options.verbose) { + console.warn('[hmr-angular][manual] Could not parse HMR function signature'); + } + triggerRebootstrap(g, options); + return true; + } + + const fnName = fnMatch[1]; // e.g., "SimpleTestComponent_UpdateMetadata" + const params = fnMatch[2] + .split(',') + .map((p) => p.trim()) + .filter(Boolean); + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular][manual] Function name:', fnName); + console.log('[hmr-angular][manual] Parameters:', params); + } + + // The first param is the component class name + // The second param is ɵɵnamespaces (Angular internal) + // The remaining params are imported modules/decorators + + const componentClassName = params[0]; // e.g., "SimpleTestComponent" + + // Parse import statements to build a module map + // Format: import { x, y as z } from 'module-path'; + const importMap: Record = {}; + const importRegex = /import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g; + let importMatch; + while ((importMatch = importRegex.exec(code)) !== null) { + const imports = importMatch[1]; + const source = importMatch[2]; + + // Parse each import: "name" or "name as alias" + imports.split(',').forEach((imp) => { + imp = imp.trim(); + if (!imp) return; + + const asMatch = imp.match(/(\w+)\s+as\s+(\w+)/); + if (asMatch) { + importMap[asMatch[2]] = { source, original: asMatch[1] }; + } else { + importMap[imp] = { source, original: imp }; + } }); + } + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular][manual] Import map:', JSON.stringify(importMap, null, 2)); + } + + // Resolve the component class + let componentClass: any = null; + + // Try to find the component from the import map + const componentImport = importMap[componentClassName]; + if (componentImport) { + // Try to get the component from our module registry + const moduleRegistry = g.__NS_HMR_MODULE_REGISTRY__; + if (moduleRegistry) { + for (const [modPath, mod] of Object.entries(moduleRegistry)) { + if (mod && typeof mod === 'object') { + const moduleObj = mod as Record; + if (moduleObj[componentClassName]) { + componentClass = moduleObj[componentClassName]; + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular][manual] Found component in module:', modPath); + } + break; + } + } + } + } + + // Also try the global component registry + if (!componentClass && g.__NS_ANGULAR_COMPONENTS__) { + componentClass = g.__NS_ANGULAR_COMPONENTS__[componentClassName]; + } + } + + if (!componentClass) { + if (options.verbose) { + console.warn('[hmr-angular][manual] Could not find component class:', componentClassName); + } + triggerRebootstrap(g, options); return true; } + + // Build the arguments array for the update function + const args: any[] = []; + for (const param of params) { + if (param === componentClassName) { + args.push(componentClass); + } else if (param === 'ɵɵnamespaces') { + // This is an array of Angular namespace objects + const core = options.getCore('@angular/core'); + const namespaces = core ? [core] : []; + args.push(namespaces); + } else { + // Look up in import map and resolve + const imp = importMap[param]; + if (imp) { + const resolved = resolveImport(imp.source, imp.original, options, g); + args.push(resolved); + } else { + // Try to get from @angular/core + const core = options.getCore('@angular/core'); + if (core && core[param]) { + args.push(core[param]); + } else { + args.push(undefined); + } + } + } + } + if (options.verbose && __NS_ENV_VERBOSE__) { - console.warn('[hmr-angular] Missing global __NS_ANGULAR_BOOTSTRAP__ factory. Set it in your main.ts to enable HMR resets.'); + console.log('[hmr-angular][manual] Resolved args count:', args.length); + } + + // Extract the function body and create a callable function + const fnStartIndex = code.indexOf(fnMatch[0]); + const fnBodyStart = code.indexOf('{', fnStartIndex); + if (fnBodyStart === -1) { + if (options.verbose) { + console.warn('[hmr-angular][manual] Could not find function body'); + } + triggerRebootstrap(g, options); + return true; + } + + // Find matching closing brace for the function + let braceCount = 0; + let fnBodyEnd = -1; + for (let i = fnBodyStart; i < code.length; i++) { + if (code[i] === '{') braceCount++; + else if (code[i] === '}') { + braceCount--; + if (braceCount === 0) { + fnBodyEnd = i; + break; + } + } + } + + if (fnBodyEnd === -1) { + if (options.verbose) { + console.warn('[hmr-angular][manual] Could not find function end'); + } + triggerRebootstrap(g, options); + return true; + } + + const fnBody = code.substring(fnBodyStart + 1, fnBodyEnd); + + // Create the function with parameters + try { + const updateFn = new Function(...params, fnBody); + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular][manual] Created update function, calling with', args.length, 'args'); + } + + // Call the update function + updateFn.apply(null, args); + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular][manual] HMR update function executed successfully'); + } + + // Trigger change detection to update the view + const appRef = g.__NS_ANGULAR_APP_REF__; + if (appRef && typeof appRef.tick === 'function') { + appRef.tick(); + } + + return true; + } catch (evalError) { + if (options.verbose) { + console.warn('[hmr-angular][manual] Function evaluation failed:', evalError); + } + triggerRebootstrap(g, options); + return true; } } catch (error) { if (options.verbose) { + console.warn('[hmr-angular][manual] failed to handle HMR code', error); + } + triggerRebootstrap(g, options); + } + return true; +} + +/** + * Detect how many namespaces the HMR code expects by parsing namespace access patterns. + * The HMR code contains lines like: const ɵhmr0 = ɵɵnamespaces[0]; + * We find the highest index to determine the number of namespaces needed. + */ +function detectExpectedNamespaces(code: string): number { + // Look for patterns like: ɵɵnamespaces[0], ɵɵnamespaces[1], etc. + const pattern = /ɵɵnamespaces\[(\d+)\]/g; + let maxIndex = -1; + let match; + while ((match = pattern.exec(code)) !== null) { + const index = parseInt(match[1], 10); + if (index > maxIndex) { + maxIndex = index; + } + } + // Also check for patterns like: const ɵhmr0 = ɵɵnamespaces[0]; + const hmrPattern = /const\s+ɵhmr(\d+)\s*=\s*ɵɵnamespaces\[(\d+)\]/g; + while ((match = hmrPattern.exec(code)) !== null) { + const index = parseInt(match[2], 10); + if (index > maxIndex) { + maxIndex = index; + } + } + // Return the count (max index + 1), minimum of 1 for @angular/core + return Math.max(1, maxIndex + 1); +} + +/** + * Resolve the arguments for an HMR update function based on its parameter names. + * The parameters typically follow this pattern: + * - First param: The component class name (e.g., SimpleTestComponent) + * - Second param: ɵɵnamespaces (array of Angular core namespaces) + * - Remaining params: Imported modules/decorators (e.g., NativeScriptCommonModule, Component) + */ +function resolveHmrFunctionArgs(params: string[], code: string, options: AngularUpdateOptions, g: any): any[] { + const args: any[] = []; + + // Load the core modules once - these are the canonical sources + const angularCore = options.getCore('@angular/core'); + const nsAngular = options.getCore('@nativescript/angular'); + const nsCore = options.getCore('@nativescript/core'); + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] resolving args for params:', params); + console.log('[hmr-angular] modules loaded - @angular/core:', !!angularCore, '@nativescript/angular:', !!nsAngular, '@nativescript/core:', !!nsCore); + } + + // The first parameter is the component class name + const componentClassName = params[0]; + + // Build ɵɵnamespaces array - this contains Angular namespace modules indexed by namespace + // The Angular compiler generates HMR code that accesses namespaces by index: + // - const ɵhmr0 = ɵɵnamespaces[0]; // typically @angular/core + // - const ɵhmr1 = ɵɵnamespaces[1]; // other namespace if needed + // + // We need to detect how many namespaces the HMR code expects by parsing it. + const namespacesExpected = detectExpectedNamespaces(code); + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] namespaces expected by HMR code:', namespacesExpected); + } + + // Load namespace modules in order + // By convention, namespace 0 is almost always @angular/core + const availableNamespaces: any[] = [angularCore, options.getCore('@angular/common'), options.getCore('@angular/router'), options.getCore('@angular/forms'), nsAngular].filter(Boolean); + + // Build namespaces array with exactly the number expected + const namespaces: any[] = []; + for (let i = 0; i < namespacesExpected; i++) { + if (i < availableNamespaces.length) { + namespaces.push(availableNamespaces[i]); + } else { + // Fill with empty object if we don't have enough namespaces + namespaces.push({}); + } + } + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] namespaces array length:', namespaces.length); + } + + // Resolve each parameter + for (let i = 0; i < params.length; i++) { + const param = params[i]; + let resolved: any = undefined; + let source = ''; + + if (param === 'ɵɵnamespaces') { + // Special: array of Angular namespace objects + resolved = namespaces; + source = 'namespaces array'; + } else if (i === 0) { + // First param is the component class - look it up + resolved = findComponentClass(param, g, options); + source = resolved ? '__NS_ANGULAR_COMPONENTS__' : 'not found'; + } else { + // Try to resolve from known modules in priority order + // 1. @angular/core - decorators, DI, etc. + if (angularCore && angularCore[param] !== undefined) { + resolved = angularCore[param]; + source = '@angular/core'; + } + // 2. @nativescript/angular - NativeScript Angular modules + else if (nsAngular && nsAngular[param] !== undefined) { + resolved = nsAngular[param]; + source = '@nativescript/angular'; + } + // 3. @nativescript/core - core UI classes + else if (nsCore && nsCore[param] !== undefined) { + resolved = nsCore[param]; + source = '@nativescript/core'; + } + // 4. Try other common Angular packages + else { + const otherPackages = ['@angular/common', '@angular/forms', '@angular/router', '@angular/platform-browser']; + for (const pkg of otherPackages) { + const mod = options.getCore(pkg); + if (mod && mod[param] !== undefined) { + resolved = mod[param]; + source = pkg; + break; + } + } + } + + // 5. Last resort: scan vendor registry for any module with this export + if (resolved === undefined) { + try { + const vendorReg: Map | undefined = g.__nsVendorRegistry; + if (vendorReg && typeof vendorReg.forEach === 'function') { + vendorReg.forEach((mod: any, path: string) => { + if (resolved === undefined && mod && typeof mod === 'object') { + if (mod[param] !== undefined) { + resolved = mod[param]; + source = `vendor:${path}`; + } else if (mod.default && mod.default[param] !== undefined) { + resolved = mod.default[param]; + source = `vendor:${path}.default`; + } + } + }); + } + } catch {} + } + } + + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log(`[hmr-angular] arg[${i}] ${param} = ${resolved !== undefined ? source : 'UNDEFINED'}`); + } + + args.push(resolved); + } + + return args; +} + +/** + * Find a component class by name from available registries. + */ +function findComponentClass(name: string, g: any, options: AngularUpdateOptions): any { + // 1. Check __NS_ANGULAR_COMPONENTS__ - registered during bootstrap + if (g.__NS_ANGULAR_COMPONENTS__ && g.__NS_ANGULAR_COMPONENTS__[name]) { + return g.__NS_ANGULAR_COMPONENTS__[name]; + } + + // 2. Check HMR module registry + const moduleRegistry = g.__NS_HMR_MODULE_REGISTRY__; + if (moduleRegistry) { + for (const [, mod] of Object.entries(moduleRegistry)) { + if (mod && typeof mod === 'object') { + const moduleObj = mod as Record; + if (moduleObj[name]) return moduleObj[name]; + } + } + } + + // 3. Check vendor registry + try { + const vendorReg: Map | undefined = g.__nsVendorRegistry; + if (vendorReg && typeof vendorReg.forEach === 'function') { + let found: any = undefined; + vendorReg.forEach((mod: any) => { + if (!found && mod && typeof mod === 'object') { + const moduleObj = mod.default || mod; + if (moduleObj && moduleObj[name]) { + found = moduleObj[name]; + } + } + }); + if (found) return found; + } + } catch {} + + // 4. Check globalThis + if (g[name]) return g[name]; + + return undefined; +} + +/** + * Resolve an import to its value using the universal module resolver. + */ +function resolveImport(source: string, name: string, options: AngularUpdateOptions, g: any): any { + // Use the universal module resolver + const mod = options.getCore(source); + if (mod) { + if (mod[name] !== undefined) return mod[name]; + if (mod.default && mod.default[name] !== undefined) return mod.default[name]; + } + + // Fallback: check module registry for relative imports + if (source.startsWith('./') || source.startsWith('../')) { + const moduleRegistry = g.__NS_HMR_MODULE_REGISTRY__; + if (moduleRegistry) { + for (const [modPath, mod] of Object.entries(moduleRegistry)) { + if (modPath.includes(source.replace(/^\.\//, '').replace(/^\.\.\//, '')) && mod && typeof mod === 'object') { + const moduleObj = mod as Record; + if (moduleObj[name] !== undefined) return moduleObj[name]; + } + } + } + } + + return undefined; +} + +function triggerRebootstrap(g: any, options: AngularUpdateOptions): void { + const rebootNgModules = g.__reboot_ng_modules__; + if (typeof rebootNgModules === 'function') { + if (options.verbose && __NS_ENV_VERBOSE__) { + console.log('[hmr-angular] falling back to re-bootstrap'); + } + try { + g.__NS_DEV_RESET_IN_PROGRESS__ = true; + rebootNgModules(false); + } catch (e) { + if (options.verbose) { + console.warn('[hmr-angular] re-bootstrap failed', e); + } + } finally { try { - console.warn('[hmr-angular] failed to handle update', (error && (error as any).message) || error); + g.__NS_DEV_RESET_IN_PROGRESS__ = false; } catch {} } } +} + +export function handleAngularHotUpdateMessage(msg: any, options: AngularUpdateOptions): boolean { + if (!msg || msg.type !== 'ns:angular-update') { + return false; + } + + const g: any = globalThis; + const verbose = options.verbose && __NS_ENV_VERBOSE__; + + try { + // Extract the changed file path from the message + const changedPath = msg.path as string | undefined; + + if (verbose) { + console.log('[hmr-angular] received update for path:', changedPath); + } + + // Increment the HMR nonce before re-bootstrap so dynamic imports get fresh modules + try { + g.__NS_HMR_IMPORT_NONCE__ = (typeof g.__NS_HMR_IMPORT_NONCE__ === 'number' ? g.__NS_HMR_IMPORT_NONCE__ : 0) + 1; + if (verbose) { + console.log('[hmr-angular] incremented HMR nonce to:', g.__NS_HMR_IMPORT_NONCE__); + } + } catch {} + + // Trigger re-bootstrap + const rebootNgModules = g.__reboot_ng_modules__; + if (typeof rebootNgModules === 'function') { + if (verbose) { + console.log('[hmr-angular] triggering re-bootstrap via __reboot_ng_modules__'); + } + try { + g.__NS_DEV_RESET_IN_PROGRESS__ = true; + // Pass false to not dispose platform (keep zone.js etc alive) + rebootNgModules(false); + if (verbose) { + console.log('[hmr-angular] re-bootstrap completed'); + } + + // Trigger change detection after re-bootstrap + // This is especially important for zoneless apps using provideZonelessChangeDetection() + setTimeout(() => { + try { + const appRef = g.__NS_ANGULAR_APP_REF__; + if (appRef && typeof appRef.tick === 'function') { + if (verbose) { + console.log('[hmr-angular] triggering change detection tick'); + } + appRef.tick(); + } + } catch (e) { + if (verbose) { + console.warn('[hmr-angular] change detection tick failed:', e); + } + } + }, 100); + } catch (e) { + console.warn('[hmr-angular] re-bootstrap failed:', e); + } finally { + try { + g.__NS_DEV_RESET_IN_PROGRESS__ = false; + } catch {} + } + return true; + } + + // Fallback: Try __bootstrap_app_ng_modules__ (just re-bootstrap without dispose) + const bootstrapNgModules = g.__bootstrap_app_ng_modules__; + if (typeof bootstrapNgModules === 'function') { + if (verbose) { + console.log('[hmr-angular] triggering re-bootstrap via __bootstrap_app_ng_modules__'); + } + try { + g.__NS_DEV_RESET_IN_PROGRESS__ = true; + bootstrapNgModules(); + } catch (e) { + console.warn('[hmr-angular] bootstrap failed:', e); + } finally { + try { + g.__NS_DEV_RESET_IN_PROGRESS__ = false; + } catch {} + } + return true; + } + + if (verbose) { + console.warn('[hmr-angular] No HMR mechanism available. Missing __reboot_ng_modules__ or __bootstrap_app_ng_modules__.'); + } + } catch (error) { + console.warn('[hmr-angular] failed to handle update:', error); + } return true; } diff --git a/packages/vite/hmr/server/angular-hmr-transformer.ts b/packages/vite/hmr/server/angular-hmr-transformer.ts new file mode 100644 index 0000000000..4bfda0b0c5 --- /dev/null +++ b/packages/vite/hmr/server/angular-hmr-transformer.ts @@ -0,0 +1,220 @@ +/** + * Angular HMR Transformer for NativeScript + * + * This module transforms Angular HMR update code from AnalogJS into a format + * that can be executed directly on NativeScript devices. + * + * The key insight: AnalogJS generates HMR code designed for browser environments + * with import.meta.hot and standard ES imports. NativeScript needs the code + * pre-bundled with all dependencies resolved. + * + * Architecture: + * 1. Parse the HMR function signature to extract parameter names + * 2. Analyze which namespaces are accessed (ɵɵnamespaces[N]) + * 3. Bundle all dependencies inline so the client just executes + * 4. Generate metadata so the client knows exactly what to provide + */ + +export interface AngularHmrMetadata { + /** Name of the component class (e.g., "SimpleTestComponent") */ + componentName: string; + + /** Relative path to the component file */ + componentPath: string; + + /** The update function name (e.g., "SimpleTestComponent_UpdateMetadata") */ + functionName: string; + + /** Parameter names in order (first is component class, second is ɵɵnamespaces, rest are locals) */ + parameters: string[]; + + /** Number of namespace modules expected (ɵɵnamespaces array length) */ + namespacesCount: number; + + /** Local dependencies (everything after ɵɵnamespaces) */ + localDependencies: LocalDependency[]; + + /** Timestamp of the update */ + timestamp: number; +} + +export interface LocalDependency { + name: string; + /** Hint about where to find this dependency */ + sourceHint: '@angular/core' | '@nativescript/angular' | '@nativescript/core' | 'component' | 'unknown'; +} + +export interface TransformedHmrCode { + /** The transformed code ready for execution */ + code: string; + + /** Metadata about the HMR update */ + metadata: AngularHmrMetadata; +} + +/** + * Parse the HMR function signature from the code. + * + * Expected format: + * export default function ComponentName_UpdateMetadata(ComponentName, ɵɵnamespaces, Local1, Local2) { + */ +function parseHmrFunctionSignature(code: string): { functionName: string; parameters: string[] } | null { + // Match: export default function FunctionName(param1, param2, ...) + const match = code.match(/export\s+default\s+function\s+(\w+)\s*\(([^)]*)\)/); + if (!match) { + return null; + } + + const functionName = match[1]; + const paramsString = match[2]; + const parameters = paramsString + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + return { functionName, parameters }; +} + +/** + * Detect how many namespaces the HMR code expects. + * + * The Angular compiler generates code like: + * const ɵhmr0 = ɵɵnamespaces[0]; + * const ɵhmr1 = ɵɵnamespaces[1]; + * + * We find the highest index to determine the array size needed. + */ +function detectNamespacesCount(code: string): number { + const matches = code.matchAll(/ɵɵnamespaces\s*\[\s*(\d+)\s*\]/g); + let maxIndex = -1; + + for (const match of matches) { + const index = parseInt(match[1], 10); + if (index > maxIndex) { + maxIndex = index; + } + } + + // Return count (max index + 1), minimum 1 for @angular/core + return Math.max(1, maxIndex + 1); +} + +/** + * Extract the component name from the function name. + * + * Function names follow the pattern: ComponentName_UpdateMetadata + */ +function extractComponentName(functionName: string): string { + const match = functionName.match(/^(\w+)_UpdateMetadata$/); + return match ? match[1] : functionName; +} + +/** + * Analyze local dependencies and provide hints about their sources. + */ +function analyzeLocalDependencies(parameters: string[]): LocalDependency[] { + // Skip first two parameters (component class and ɵɵnamespaces) + const locals = parameters.slice(2); + + const angularCoreExports = new Set(['Component', 'Directive', 'Injectable', 'Pipe', 'NgModule', 'Input', 'Output', 'ViewChild', 'ViewChildren', 'ContentChild', 'ContentChildren', 'HostBinding', 'HostListener', 'Inject', 'Optional', 'Self', 'SkipSelf', 'Host', 'ChangeDetectionStrategy', 'ViewEncapsulation', 'EventEmitter', 'ElementRef', 'TemplateRef', 'ViewContainerRef', 'ChangeDetectorRef', 'Renderer2', 'NgZone', 'ApplicationRef', 'Injector', 'signal', 'computed', 'effect', 'input', 'output', 'model', 'viewChild', 'viewChildren', 'contentChild', 'contentChildren']); + + const nsAngularExports = new Set(['NativeScriptModule', 'NativeScriptCommonModule', 'NativeScriptFormsModule', 'NativeScriptRouterModule', 'NativeScriptHttpClientModule', 'registerElement', 'RouterExtensions', 'PageRoute', 'NSLocationStrategy']); + + const nsCoreExports = new Set(['Application', 'Frame', 'Page', 'View', 'Label', 'Button', 'TextField', 'ListView', 'ScrollView', 'StackLayout', 'GridLayout', 'FlexboxLayout', 'AbsoluteLayout', 'DockLayout', 'WrapLayout', 'Image', 'WebView', 'Observable', 'ObservableArray', 'Color', 'Screen', 'Device', 'Utils']); + + return locals.map((name) => { + let sourceHint: LocalDependency['sourceHint'] = 'unknown'; + + if (angularCoreExports.has(name)) { + sourceHint = '@angular/core'; + } else if (nsAngularExports.has(name)) { + sourceHint = '@nativescript/angular'; + } else if (nsCoreExports.has(name)) { + sourceHint = '@nativescript/core'; + } + + return { name, sourceHint }; + }); +} + +/** + * Transform the HMR code into a self-executing module format. + * + * Instead of relying on the client to parse and execute the function, + * we wrap it in a format that: + * 1. Exports the function as default + * 2. Provides metadata about expected parameters + * 3. Is ready for direct execution via dynamic import + */ +function transformCode(code: string, metadata: AngularHmrMetadata): string { + // The code is already a valid ES module with `export default function ...` + // We just need to ensure it's clean and add our metadata + + // Remove any import statements (they won't resolve on the client) + // The HMR code from AnalogJS should already have no imports + let transformed = code; + + // Add our metadata as an export so the client can read it + const metadataExport = ` +// NativeScript HMR Metadata +export const __nsHmrMetadata__ = ${JSON.stringify(metadata, null, 2)}; +`; + + // Append metadata after the function + transformed = transformed + '\n' + metadataExport; + + return transformed; +} + +/** + * Transform Angular HMR code from AnalogJS into NativeScript-ready format. + * + * @param rawCode The raw HMR code from AnalogJS's /@ng/component endpoint + * @param componentPath The relative path to the component file + * @param timestamp The update timestamp + * @returns Transformed code with metadata, or null if parsing fails + */ +export function transformAngularHmrCode(rawCode: string, componentPath: string, timestamp: number): TransformedHmrCode | null { + // Parse the function signature + const signature = parseHmrFunctionSignature(rawCode); + if (!signature) { + console.warn('[angular-hmr] Failed to parse HMR function signature'); + return null; + } + + // Extract component name from function name + const componentName = extractComponentName(signature.functionName); + + // Detect how many namespaces are needed + const namespacesCount = detectNamespacesCount(rawCode); + + // Analyze local dependencies + const localDependencies = analyzeLocalDependencies(signature.parameters); + + // Build metadata + const metadata: AngularHmrMetadata = { + componentName, + componentPath, + functionName: signature.functionName, + parameters: signature.parameters, + namespacesCount, + localDependencies, + timestamp, + }; + + // Transform the code + const code = transformCode(rawCode, metadata); + + return { code, metadata }; +} + +/** + * Create a WebSocket message payload for Angular HMR. + */ +export function createAngularHmrMessage(transformed: TransformedHmrCode): object { + return { + type: 'ns:angular-hmr-v2', + code: transformed.code, + metadata: transformed.metadata, + }; +} diff --git a/packages/vite/hmr/server/websocket.ts b/packages/vite/hmr/server/websocket.ts index 1586831d46..7a1a6f48e8 100644 --- a/packages/vite/hmr/server/websocket.ts +++ b/packages/vite/hmr/server/websocket.ts @@ -33,6 +33,7 @@ import { typescriptServerStrategy } from '../frameworks/typescript/server/strate import { buildInlineTemplateBlock, createProcessSfcCode, extractTemplateRender, processTemplateVariantMinimal } from '../frameworks/vue/server/sfc-transforms.js'; import { astExtractImportsAndStripTypes } from '../helpers/ast-extract.js'; import { getProjectAppPath, getProjectAppRelativePath, getProjectAppVirtualPath } from '../../helpers/utils.js'; +import { transformAngularHmrCode, createAngularHmrMessage } from './angular-hmr-transformer.js'; const { parse, compileTemplate, compileScript } = vueSfcCompiler; @@ -117,6 +118,36 @@ function isLikelyNativeScriptPluginSpecifier(spec: string): boolean { return true; } +// Normalize Angular fesm2022 deep imports to their base package names. +// This ensures imports like '@nativescript/angular/fesm2022/nativescript-angular.mjs' +// are properly resolved through the vendor registry as '@nativescript/angular'. +// Also handles full filesystem paths like '/node_modules/@nativescript/angular/fesm2022/...' +function normalizeAngularDeepImport(spec: string): string { + if (!spec) return spec; + + // Handle full filesystem paths with node_modules prefix + // Match: /...node_modules/@angular/xxx/fesm2022/*.mjs -> @angular/xxx + const fsAngularMatch = spec.match(/\/node_modules\/@angular\/([^/]+)\/fesm2022\/.*\.mjs$/); + if (fsAngularMatch) { + return `@angular/${fsAngularMatch[1]}`; + } + // Match: /...node_modules/@nativescript/angular/fesm2022/*.mjs -> @nativescript/angular + if (/\/node_modules\/@nativescript\/angular\/fesm2022\/.*\.mjs$/.test(spec)) { + return '@nativescript/angular'; + } + + // Match @angular/xxx/fesm2022/*.mjs -> @angular/xxx + const angularMatch = spec.match(/^@angular\/([^/]+)\/fesm2022\/.*\.mjs$/); + if (angularMatch) { + return `@angular/${angularMatch[1]}`; + } + // Match @nativescript/angular/fesm2022/*.mjs -> @nativescript/angular + if (/^@nativescript\/angular\/fesm2022\/.*\.mjs$/.test(spec)) { + return '@nativescript/angular'; + } + return spec; +} + export function ensureNativeScriptModuleBindings(code: string): string { // Proceed even if a vendor manifest isn't available; we'll still vendor-bind // likely NativeScript plugin-style specifiers (e.g., 'pinia', '@scope/pkg') @@ -174,7 +205,8 @@ export function ensureNativeScriptModuleBindings(code: string): string { preservedImports.push(original); return pfx || ''; } - const specifier = rawSpec.replace(PAT.QUERY_PATTERN, ''); + // Normalize Angular fesm2022 deep imports to base package + const specifier = normalizeAngularDeepImport(rawSpec.replace(PAT.QUERY_PATTERN, '')); let canonical = resolveVendorFromCandidate(specifier); // If not found in vendor manifest, treat well-known NativeScript plugin-style packages // as require() based modules so the device can resolve them from the app bundle or vendor. @@ -224,7 +256,8 @@ export function ensureNativeScriptModuleBindings(code: string): string { // Handle side-effect only imports: import 'x' code = code.replace(sideEffectRegex, (full: string, _pfx: string, rawSpec: string) => { const original = full.replace(/^\n/, ''); - const specifier = rawSpec.replace(PAT.QUERY_PATTERN, ''); + // Normalize Angular fesm2022 deep imports to base package + const specifier = normalizeAngularDeepImport(rawSpec.replace(PAT.QUERY_PATTERN, '')); let canonical = resolveVendorFromCandidate(specifier); if (!canonical && isLikelyNativeScriptPluginSpecifier(specifier)) { canonical = specifier; @@ -381,17 +414,32 @@ function normalizeNodeModulesSpecifier(spec: string): string | null { return subPath.startsWith('/') ? subPath.slice(1) : subPath; } +// Packages that are registered via the main-entry bootstrap code in Angular flavor +// and should be recognized as vendor modules even if not in the manifest. +const ANGULAR_BOOTSTRAP_VENDORS = new Set(['@nativescript/angular', '@angular/core', '@angular/common', '@angular/router', '@angular/forms', '@angular/platform-browser', '@angular/common/http', '@angular/animations', '@angular/animations/browser']); + function resolveVendorFromCandidate(specifier: string | null | undefined): string | null { if (!specifier) { return null; } + const cleaned = specifier.replace(PAT.QUERY_PATTERN, ''); + + // Check if this is an Angular vendor module registered via main-entry bootstrap + if (ANGULAR_BOOTSTRAP_VENDORS.has(cleaned)) { + return cleaned; + } + // Also check for @angular/* subpaths that should map to base package + const angularPkgMatch = cleaned.match(/^(@angular\/[^/]+)/); + if (angularPkgMatch && ANGULAR_BOOTSTRAP_VENDORS.has(angularPkgMatch[1])) { + return angularPkgMatch[1]; + } + const manifest = getVendorManifest(); if (!manifest) { return null; } - const cleaned = specifier.replace(PAT.QUERY_PATTERN, ''); const direct = resolveVendorSpecifier(cleaned); if (direct) { return direct; @@ -604,6 +652,42 @@ function stripViteDynamicImportVirtual(code: string): string { return code; } +/** + * Rewrite Angular fesm2022 deep import paths to base package names. + * These deep paths are not resolvable on device - they need to go through the vendor registry. + * Examples: + * @nativescript/angular/fesm2022/nativescript-angular.mjs -> @nativescript/angular + * @angular/core/fesm2022/core.mjs -> @angular/core + * @angular/router/fesm2022/router.mjs -> @angular/router + * /node_modules/@nativescript/angular/fesm2022/... -> @nativescript/angular + * /@fs/.../node_modules/@nativescript/angular/fesm2022/... -> @nativescript/angular + */ +function rewriteAngularFesm2022Paths(code: string): string { + if (!/\/fesm2022\/[^"']+\.mjs/.test(code) && !/@nativescript\/angular/.test(code)) { + return code; + } + const original = code; + + // Handle full filesystem paths with node_modules (Vite /@fs/ or /node_modules/ prefixes) + // Match: "/@fs/.../node_modules/@nativescript/angular/fesm2022/..." or "/node_modules/@nativescript/angular/fesm2022/..." + code = code.replace(/(["'])((?:\/@fs)?[^"']*\/node_modules\/)@nativescript\/angular\/fesm2022\/[^"']+\.mjs\1/g, '$1@nativescript/angular$1'); + code = code.replace(/(["'])((?:\/@fs)?[^"']*\/node_modules\/)@angular\/([^/]+)\/fesm2022\/[^"']+\.mjs\1/g, '$1@angular/$3$1'); + + // Rewrite @nativescript/angular/fesm2022/*.mjs -> @nativescript/angular + // Handle: import X from '...', export { X } from '...', export * from '...' + code = code.replace(/((?:from|import)\s*\(\s*['"]|from\s+['"])@nativescript\/angular\/fesm2022\/[^"']+\.mjs(['"])/g, '$1@nativescript/angular$2'); + // Rewrite @angular/XXX/fesm2022/*.mjs -> @angular/XXX + code = code.replace(/((?:from|import)\s*\(\s*['"]|from\s+['"])@angular\/([^/]+)\/fesm2022\/[^"']+\.mjs(['"])/g, '$1@angular/$2$3'); + // Also catch bare string literals that might be used for require() or other dynamic resolution + // Match: '@nativescript/angular/fesm2022/xxx.mjs' or "@nativescript/angular/fesm2022/xxx.mjs" + code = code.replace(/(['"])@nativescript\/angular\/fesm2022\/[^"']+\.mjs\1/g, '$1@nativescript/angular$1'); + code = code.replace(/(['"])@angular\/([^/]+)\/fesm2022\/[^"']+\.mjs\1/g, '$1@angular/$2$1'); + if (code !== original) { + code = `// [hmr-sanitize] rewrote Angular fesm2022 paths\n${code}`; + } + return code; +} + // Small snippet injected into device-delivered modules to capture any require('http(s)://') calls const REQUIRE_GUARD_SNIPPET = `// [guard] install require('http(s)://') detector\n(()=>{try{var g=globalThis;if(g.__NS_REQUIRE_GUARD_INSTALLED__){}else{var mk=function(o,l){return function(){try{var s=arguments[0];if(typeof s==='string'&&/^(?:https?:)\\/\\//.test(s)){var e=new Error('[ns-hmr][require-guard] require of URL: '+s+' via '+l);try{console.error(e.message+'\\n'+(e.stack||''));}catch(e2){}try{g.__NS_REQUIRE_GUARD_LAST__={spec:s,stack:e.stack,label:l,ts:Date.now()};}catch(e3){}}}catch(e1){}return o.apply(this, arguments);};};if(typeof g.require==='function'&&!g.require.__NS_REQ_GUARDED__){var o1=g.require;g.require=mk(o1,'require');g.require.__NS_REQ_GUARDED__=true;}if(typeof g.__nsRequire==='function'&&!g.__nsRequire.__NS_REQ_GUARDED__){var o2=g.__nsRequire;g.__nsRequire=mk(o2,'__nsRequire');g.__nsRequire.__NS_REQ_GUARDED__=true;}g.__NS_REQUIRE_GUARD_INSTALLED__=true;}}catch(e){}})();\n`; @@ -1126,6 +1210,10 @@ function normalizeAbsoluteFilesystemImport(spec: string, importerPath: string, p function processCodeForDevice(code: string, isVitePreBundled: boolean): string { let result = code; + // Rewrite Angular fesm2022 deep paths to base package names FIRST + // These paths are not resolvable on device - they need to go through the vendor registry + result = rewriteAngularFesm2022Paths(result); + // Ensure Angular partial declarations are linked before any sanitizers run so runtime never hits the JIT path. result = linkAngularPartialsIfNeeded(result); @@ -1828,6 +1916,60 @@ function rewriteImports(code: string, importerPath: string, sfcFileMap: Map `__NGC${angularCoreAliasIdx++}`; + const angularCoreUrl = (sub?: string) => { + const p = (sub || '').replace(/^\//, ''); + return `${httpOriginSafe || ''}/ns/angular-core` + (p ? `?p=${p}` : ''); + }; + // Case 1: import * as i0 from '@angular/core[/sub]' + result = result.replace(/(^|\n)\s*import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*["']@angular\/core([^"'\n]*)["'];?/g, (_m, pfx: string, nsName: string, sub: string) => { + const url = angularCoreUrl(sub || ''); + return `${pfx}import * as ${nsName} from ${JSON.stringify(url)};`; + }); + // Case 2: import { A, B } from '@angular/core[/sub]' + result = result.replace(/(^|\n)\s*import\s*\{\s*([^}]+?)\s*\}\s*from\s+["']@angular\/core([^"'\n]*)["'];?/g, (_m, pfx: string, names: string, sub: string) => { + const alias = mkAngularAlias(); + const url = angularCoreUrl(sub || ''); + const cleaned = names + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .join(', '); + return `${pfx}import * as ${alias} from ${JSON.stringify(url)};\nconst { ${cleaned} } = ${alias};`; + }); + // Case 3: import Default, { A, B } from '@angular/core[/sub]' + result = result.replace(/(^|\n)\s*import\s+([A-Za-z_$][\w$]*)\s*,\s*\{([^}]+?)\s*\}\s*from\s*["']@angular\/core([^"'\n]*)["'];?/g, (_m, pfx: string, defName: string, names: string, sub: string) => { + const alias = mkAngularAlias(); + const url = angularCoreUrl(sub || ''); + const cleaned = names + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .join(', '); + return `${pfx}import * as ${alias} from ${JSON.stringify(url)};\nconst ${defName} = (${alias}.default || ${alias});\nconst { ${cleaned} } = ${alias};`; + }); + // Case 4: import Default from '@angular/core[/sub]' + result = result.replace(/(^|\n)\s*import\s+([A-Za-z_$][\w$]*)\s+from\s*["']@angular\/core([^"'\n]*)["'];?/g, (_m, pfx: string, defName: string, sub: string) => { + const alias = mkAngularAlias(); + const url = angularCoreUrl(sub || ''); + return `${pfx}import * as ${alias} from ${JSON.stringify(url)};\nconst ${defName} = (${alias}.default || ${alias});`; + }); + // Case 5: side-effect import '@angular/core[/sub]' + result = result.replace(/(^|\n)\s*import\s*["']@angular\/core([^"'\n]*)["'];?/g, (_m, pfx: string, sub: string) => { + const url = angularCoreUrl(sub || ''); + return `${pfx}import ${JSON.stringify(url)};`; + }); + // Case 6: dynamic import('@angular/core[/sub]') + result = result.replace(/import\(\s*["']@angular\/core([^"'\n]*)["']\s*\)/g, (_m, sub: string) => { + const url = angularCoreUrl(sub || ''); + return `import(${JSON.stringify(url)})`; + }); + } catch {} + // Inline JSON imports (package.json, config.json, etc.) // This must happen BEFORE other rewrites because JSON imports get a ?import query added by Vite result = result.replace(/import\s+(\w+)\s+from\s+["']([^"']+\.json(?:\?[^"']*)?)["'];?/g, (match, varName, jsonPath) => { @@ -2456,6 +2598,129 @@ function createHmrWebSocketPlugin(opts: { verbose?: boolean }): Plugin { } catch {} }); + // Listen for AnalogJS's Angular component HMR updates when liveReload is enabled. + // This intercepts the 'angular:component-update' message that AnalogJS sends via Vite's WS + // and forwards the HMR update code to device clients using the v2 transformer. + server.ws.on('angular:component-update', async (data: { id: string; timestamp: number }) => { + try { + if (verbose) { + console.log('[hmr-ws][angular-v2] Received component update:', data.id); + } + + // Forward the angular:component-update event to device clients + // This allows Angular's native HMR mechanism (import.meta.hot.on) to work + const eventMsg = { + type: 'ns:hot-event', + event: 'angular:component-update', + data: { id: data.id, timestamp: data.timestamp }, + }; + wss!.clients.forEach((client) => { + if (client.readyState === client.OPEN) { + try { + client.send(JSON.stringify(eventMsg)); + if (verbose) { + console.log('[hmr-ws] Forwarded angular:component-update to client'); + } + } catch (sendErr) { + if (verbose) { + console.warn('[hmr-ws] Failed to forward event to client:', sendErr); + } + } + } + }); + + // Fetch the HMR update code from AnalogJS's live reload endpoint + // Format: /@ng/component?c= + const componentUrl = `/@ng/component?c=${data.id}`; + + try { + // Use Vite's transform to get the code + const result = await server.transformRequest(componentUrl); + let hmrCode = result?.code || ''; + + if (!hmrCode || hmrCode.trim().length === 0) { + // Sometimes the code comes empty initially, try middleware directly + if (verbose) { + console.log('[hmr-ws][angular-v2] No code from transform, trying fetch'); + } + + // Fetch via HTTP from the dev server + const origin = `http://localhost:${(server as any).config?.server?.port || 5173}`; + try { + const resp = await fetch(`${origin}${componentUrl}`); + if (resp.ok) { + hmrCode = await resp.text(); + } + } catch (fetchErr) { + if (verbose) { + console.warn('[hmr-ws][angular-v2] Fetch failed:', fetchErr); + } + } + } + + if (!hmrCode || hmrCode.trim().length === 0) { + if (verbose) { + console.log('[hmr-ws][angular-v2] No HMR code available, falling back to re-bootstrap'); + } + // Fall back to regular angular update which triggers re-bootstrap + return; + } + + if (verbose) { + console.log('[hmr-ws][angular-v2] Got HMR code, length:', hmrCode.length); + console.log('[hmr-ws][angular-v2] Raw code preview:', hmrCode.substring(0, 500)); + } + + // Extract just the file path from the id (format: path@ComponentName) + // e.g., "src/simple-test/simple-test.component.ts@SimpleTestComponent" -> "src/simple-test/simple-test.component.ts" + const componentPath = data.id.includes('@') ? data.id.split('@')[0] : data.id; + + // Use the v2 transformer to analyze and prepare the HMR payload + const transformed = transformAngularHmrCode(hmrCode, componentPath, data.timestamp); + + if (!transformed) { + if (verbose) { + console.warn('[hmr-ws][angular-v2] Transform failed: could not parse HMR code'); + } + return; + } + + // Create the message using the helper + const hmrMsg = createAngularHmrMessage(transformed); + + wss!.clients.forEach((client) => { + if (client.readyState === client.OPEN) { + try { + client.send(JSON.stringify(hmrMsg)); + } catch (sendErr) { + if (verbose) { + console.warn('[hmr-ws][angular-v2] Failed to send HMR payload to client:', sendErr); + } + } + } + }); + + if (verbose) { + console.log('[hmr-ws][angular-v2] Sent HMR payload to', wss!.clients.size, 'clients'); + console.log('[hmr-ws][angular-v2] Payload summary:', { + componentName: transformed.metadata.componentName, + functionName: transformed.metadata.functionName, + namespaceCount: transformed.metadata.namespacesCount, + localDependencies: transformed.metadata.localDependencies.map((d) => d.name), + }); + } + } catch (transformErr) { + if (verbose) { + console.warn('[hmr-ws][angular-v2] Failed to get HMR code:', transformErr); + } + } + } catch (err) { + if (verbose) { + console.warn('[hmr-ws][angular-v2] Error handling component update:', err); + } + } + }); + // Dev-only HTTP ESM loader endpoint for device clients // 1) Legacy JSON module endpoint (kept temporarily): GET /ns-module?path=/abs -> { path, code, additionalFiles } server.middlewares.use(async (req, res, next) => { @@ -3265,14 +3530,110 @@ export const piniaSymbol = p.piniaSymbol; const sub = urlObj.searchParams.get('p') || ''; const key = sub ? `@nativescript/core/${sub}` : `@nativescript/core`; // HTTP-only core bridge: do NOT use require/createRequire. Export a proxy that maps - // property access to globalThis first, then to any available vendor registry module. + // property access to the vendor registry, globalThis.__nativescriptCore (for Angular), or globalThis. let code = REQUIRE_GUARD_SNIPPET + `// [ns-core-bridge][v${ver}] HTTP-only ESM bridge (default proxy only)\n` + `const g = globalThis;\n` + `const reg = (g.__nsVendorRegistry ||= new Map());\n` + - `const __getVendorCore = () => { try { const m = reg && reg.get ? (reg.get(${JSON.stringify(key)}) || reg.get('@nativescript/core')) : null; return (m && (m.__esModule && m.default ? m.default : (m.default || m))) || m || null; } catch { return null; } };\n` + - `const __core = new Proxy({}, { get(_t, p){ if (p === 'default') return __core; if (p === Symbol.toStringTag) return 'Module'; try { const v = g[p]; if (v !== undefined) return v; } catch {} try { const vc = __getVendorCore(); return vc ? vc[p] : undefined; } catch {} return undefined; } });\n` + + `const __getVendorCore = () => {\n` + + ` try {\n` + + ` // First try vendor registry\n` + + ` const m = reg && reg.get ? (reg.get(${JSON.stringify(key)}) || reg.get('@nativescript/core')) : null;\n` + + ` if (m) return (m && (m.__esModule && m.default ? m.default : (m.default || m))) || m;\n` + + ` } catch {}\n` + + ` // Fallback to Angular-style direct core registration\n` + + ` try {\n` + + ` const directCore = g.__nativescriptCore;\n` + + ` if (directCore) return directCore;\n` + + ` } catch {}\n` + + ` return null;\n` + + `};\n` + + `const __core = new Proxy({}, {\n` + + ` get(_t, p) {\n` + + ` if (p === 'default') return __core;\n` + + ` if (p === Symbol.toStringTag) return 'Module';\n` + + ` // First try vendor core module (most reliable)\n` + + ` try {\n` + + ` const vc = __getVendorCore();\n` + + ` if (vc && vc[p] !== undefined) return vc[p];\n` + + ` } catch {}\n` + + ` // Fallback to globalThis for things like Frame, Application, Page\n` + + ` try {\n` + + ` const v = g[p];\n` + + ` if (v !== undefined) return v;\n` + + ` } catch {}\n` + + ` return undefined;\n` + + ` }\n` + + `});\n` + + `// Default export: namespace-like proxy\n` + + `export default __core;\n`; + res.statusCode = 200; + res.end(code); + } catch (e) { + next(); + } + }); + + // 2.6b) ESM bridge for @angular/core: GET /ns/angular-core[/][?p=sub/path] + // This ensures Angular's internal APIs (ɵɵdefineComponent, etc.) are available during HMR + server.middlewares.use(async (req, res, next) => { + try { + const urlObj = new URL(req.url || '', 'http://localhost'); + if (!(urlObj.pathname === '/ns/angular-core' || /^\/ns\/angular-core\/[\d]+$/.test(urlObj.pathname))) return next(); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + const verSeg = urlObj.pathname.replace(/^\/ns\/angular-core\/?/, ''); + const ver = /^[0-9]+$/.test(verSeg) ? verSeg : String(graphVersion || 0); + const sub = urlObj.searchParams.get('p') || ''; + const key = sub ? `@angular/core/${sub}` : `@angular/core`; + // HTTP-only Angular core bridge: export a proxy that maps property access to the vendor registry + // This is critical for Angular HMR because compiled components use internal APIs like ɵɵdefineComponent + let code = + REQUIRE_GUARD_SNIPPET + + `// [ns-angular-core-bridge][v${ver}] HTTP-only ESM bridge for @angular/core\n` + + `const g = globalThis;\n` + + `const reg = (g.__nsVendorRegistry ||= new Map());\n` + + `const __getAngularCore = () => {\n` + + ` try {\n` + + ` // First try vendor registry\n` + + ` const m = reg && reg.get ? (reg.get(${JSON.stringify(key)}) || reg.get('@angular/core')) : null;\n` + + ` if (m) return m;\n` + + ` } catch {}\n` + + ` // Fallback to globalThis.__angularCore (set by main-entry)\n` + + ` try {\n` + + ` const directCore = g.__angularCore;\n` + + ` if (directCore) return directCore;\n` + + ` } catch {}\n` + + ` return null;\n` + + `};\n` + + `const __angularCore = __getAngularCore();\n` + + `if (!__angularCore) {\n` + + ` console.error('[ns-angular-core-bridge] @angular/core not found in vendor registry or globalThis');\n` + + `}\n` + + `// Create a proxy that forwards all property access to the actual @angular/core module\n` + + `const __core = new Proxy({}, {\n` + + ` get(_t, p) {\n` + + ` if (p === 'default') return __core;\n` + + ` if (p === Symbol.toStringTag) return 'Module';\n` + + ` if (p === '__esModule') return true;\n` + + ` try {\n` + + ` const ac = __getAngularCore();\n` + + ` if (ac && ac[p] !== undefined) return ac[p];\n` + + ` } catch {}\n` + + ` return undefined;\n` + + ` },\n` + + ` has(_t, p) {\n` + + ` try {\n` + + ` const ac = __getAngularCore();\n` + + ` return ac && p in ac;\n` + + ` } catch {}\n` + + ` return false;\n` + + ` }\n` + + `});\n` + `// Default export: namespace-like proxy\n` + `export default __core;\n`; res.statusCode = 200; @@ -4934,18 +5295,23 @@ export const piniaSymbol = p.piniaSymbol; return; } // Graph update for this file change (wrapped to avoid aborting rest of handler) - try { - const mod = server.moduleGraph.getModuleById(file) || server.moduleGraph.getModuleById(file + '?vue'); - if (mod) { - const deps = Array.from(mod.importedModules) - .map((m) => (m.id || '').replace(/\?.*$/, '')) - .filter(Boolean); - const transformed = await server.transformRequest(mod.id!); - const code = transformed?.code || ''; - upsertGraphModule((mod.id || '').replace(/\?.*$/, ''), code, deps); + // Skip HTML files - they are Angular templates processed inline by the Angular compiler, + // not standalone JS modules. Attempting to transform them causes parse errors. + const isHtmlFile = file.endsWith('.html'); + if (!isHtmlFile) { + try { + const mod = server.moduleGraph.getModuleById(file) || server.moduleGraph.getModuleById(file + '?vue'); + if (mod) { + const deps = Array.from(mod.importedModules) + .map((m) => (m.id || '').replace(/\?.*$/, '')) + .filter(Boolean); + const transformed = await server.transformRequest(mod.id!); + const code = transformed?.code || ''; + upsertGraphModule((mod.id || '').replace(/\?.*$/, ''), code, deps); + } + } catch (e) { + if (verbose) console.warn('[hmr-ws][v2] failed graph update', e); } - } catch (e) { - if (verbose) console.warn('[hmr-ws][v2] failed graph update', e); } const root = server.config.root || process.cwd(); @@ -4995,15 +5361,116 @@ export const piniaSymbol = p.piniaSymbol; const isHtml = file.endsWith('.html'); const isTs = file.endsWith('.ts'); if (!(isHtml || isTs)) return; + + const root = server.config.root || process.cwd(); + const rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/'); + const origin = getServerOrigin(server); + const timestamp = Date.now(); + + // Try to fetch HMR update code from AnalogJS's live reload plugin. + // This uses Angular's ɵɵreplaceMetadata to patch component definitions in place. + let hmrCode: string | null = null; + + if (isTs) { + try { + // Wait a bit for AnalogJS to finish processing the file + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Try to extract the component class name from the file + const fs = await import('fs'); + const fileContent = fs.readFileSync(file, 'utf-8'); + const classMatch = fileContent.match(/export\s+class\s+(\w+)/); + const className = classMatch ? classMatch[1] : null; + + if (className && verbose) { + console.log('[hmr-ws][angular-v2] Detected component class:', className); + } + + if (className) { + // The component ID format used by AnalogJS is: "relative/path.ts@ClassName" + const relPath = rel.replace(/^\//, ''); + const componentId = encodeURIComponent(`${relPath}@${className}`); + const componentUrl = `/@ng/component?c=${componentId}`; + + if (verbose) { + console.log('[hmr-ws][angular-v2] Trying to fetch HMR code from:', componentUrl); + } + + // Fetch via HTTP from the dev server + const port = (server as any).config?.server?.port || 5173; + try { + const resp = await fetch(`http://localhost:${port}${componentUrl}`); + if (resp.ok) { + const code = await resp.text(); + if (code && code.trim().length > 0 && !code.includes(' { + if (client.readyState === client.OPEN) { + try { + client.send(JSON.stringify(hmrMsg)); + } catch {} + } + }); + + if (verbose) { + console.log('[hmr-ws][angular-v2] Sent HMR payload to', wss.clients.size, 'clients'); + console.log('[hmr-ws][angular-v2] Payload summary:', { + componentName: transformed.metadata.componentName, + functionName: transformed.metadata.functionName, + namespaceCount: transformed.metadata.namespacesCount, + localDependencies: transformed.metadata.localDependencies.map((d) => d.name), + }); + } + + // Don't send ns:angular-update - the HMR code will handle it + return; + } + } + } catch (err) { + if (verbose) { + console.log('[hmr-ws][angular-v2] HMR code fetch error:', err); + } + } + } + + // Fallback: Send regular update message (triggers re-bootstrap) try { - const root = server.config.root || process.cwd(); - const rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/'); - const origin = getServerOrigin(server); + if (verbose) { + console.log('[hmr-ws][angular-v2] Falling back to re-bootstrap for:', rel); + } const msg = { type: 'ns:angular-update', origin, path: rel, - timestamp: Date.now(), + timestamp, } as const; wss.clients.forEach((client) => { if (client.readyState === client.OPEN) { @@ -5011,7 +5478,7 @@ export const piniaSymbol = p.piniaSymbol; } }); } catch (error) { - console.warn('[hmr-ws][angular] update failed:', error); + console.warn('[hmr-ws][angular-v2] update failed:', error); } return; }