Repository navigation
Description
@nativescript/vite: HMR graph collapses distinct native and shared source identities
HmrModuleGraph.normalizeGraphId maps distinct files in the native app and an external shared library to the same graph ID when both paths contain the app directory name (normally src). Registering both files leaves one graph entry.
Verified scope
- Unpatched
@nativescript/vite8.0.20, Vite 8.3.0, Node 26.12.0 - The reproduction invokes the production HMR graph directly. It does not launch an app or use Coreframe/Octane transform plugins.
- The same normalization remains in upstream
mainby source inspection on 2026-10-22. Fetched Git blob SHA:06b4744c17e7b3f8d8221f3dbeebb16a2fd708a7. Main has not been device-tested.
Expected and observed
With /workspace/native as the plugin root and /src as the app virtual path:
| Source | Actual graph ID |
|---|---|
/workspace/native/src/screen.tsx |
/src/screen.tsx |
/workspace/shared/src/screen.tsx |
/src/screen.tsx |
Expected: distinct files retain distinct identities, and registering both produces two graph entries. Actual: the second registration replaces the first; graph size is one.
The normalizer strips the plugin-root prefix, then truncates every remaining path at the first app-root substring. That last step also truncates external filesystem and shared-library identities. Source: packages/vite/hmr/server/hmr-module-graph.ts, normalizeGraphId.
Reproduction
Create the following files in an empty directory. Run pnpm install, then pnpm check. The check writes result.json and intentionally fails because the two graph identities collide.
package.json:
{
"name": "nativescript-hmr-identity-repro",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.5.1",
"scripts": {
"check": "node repro.mjs"
},
"devDependencies": {
"@nativescript/vite": "8.0.20",
"vite": "8.3.0"
}
}pnpm-workspace.yaml:
packages:
- .
allowBuilds:
esbuild: true
"@parcel/watcher": true
minimumReleaseAgeExclude:
- "@nativescript/vite@8.0.20"repro.mjs:
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const require = createRequire(import.meta.url)
const ns = pathToFileURL(require.resolve('@nativescript/vite'))
const { HmrModuleGraph } = await import(new URL('./hmr/server/hmr-module-graph.js', ns))
const { getProjectAppVirtualPath } = await import(new URL('./helpers/utils.js', ns))
const app = getProjectAppVirtualPath().replace(/^\/+|\/+$/g, '')
const graph = new HmrModuleGraph({
verbose: false,
strategy: { matchesFile: () => true },
getWss: () => null,
getPluginRoot: () => '/workspace/native',
})
const native = `/workspace/native/${app}/screen.tsx`
const shared = `/workspace/shared/${app}/screen.tsx`
const nativeId = graph.normalizeGraphId(native)
const sharedId = graph.normalizeGraphId(shared)
graph.upsert(native, 'export const origin = "native"', [], { bumpVersion: false })
graph.upsert(shared, 'export const origin = "shared"', [], { bumpVersion: false })
const result = {
scope: 'Unpatched NativeScript 8.0.20 production HMR graph, no Coreframe or Octane transform plugins and no device runtime',
appVirtualPath: getProjectAppVirtualPath(),
native, shared, nativeId, sharedId,
distinctIds: nativeId !== sharedId,
graphSizeForTwoFiles: graph.size,
}
await writeFile(new URL('./result.json', import.meta.url), JSON.stringify(result, null, 2) + '\n')
console.log(JSON.stringify(result, null, 2))
assert.notEqual(nativeId, sharedId, 'Distinct native and shared files must retain distinct HMR identities')
assert.equal(graph.size, 2)Related consumer evidence
A shared-source app declares its external source roots through a conventional tsconfig.json and TypeScript path mappings. Both iOS and Android render the app after dependency declarations are correct. On Android, a TSX component inside the native host accepts an edit and restoration without changing the app PID. In the same running app, the shared TSX screen does not display its edit within 90 seconds, although the served source contains the changed text and the server reports one HMR recipient. iOS also fails the shared-screen edit.
The shared update is reported as file=/../src/app/demo-flow/home-screen.tsx, with invalidated=0 recipients=1. The native-host control updates at /src/hmr-control.tsx. These device observations narrow the problem to shared-source delivery, but do not by themselves prove this normalizer is the only cause. No identity patch has been device-tested.
Requested fix
Preserve distinct canonical identities for modules outside the native host root and keep graph lookup, change broadcasts, client accept registration, and module fetching consistent. Retain ordinary app-relative IDs and existing query/file-URL normalization. Please cover same-named files in separate roots, external workspace libraries, and a real shared-source edit that reaches the running native view.
Activity