diff --git a/test-app/app/src/main/assets/app/hmrHotKeyExt.js b/test-app/app/src/main/assets/app/hmrHotKeyExt.js new file mode 100644 index 000000000..991402583 --- /dev/null +++ b/test-app/app/src/main/assets/app/hmrHotKeyExt.js @@ -0,0 +1,50 @@ +export function getHotData() { + return import.meta?.hot?.data; +} + +export function setHotValue(value) { + const hot = import.meta?.hot; + if (!hot || !hot.data) { + throw new Error("import.meta.hot.data is not available"); + } + hot.data.value = value; + return hot.data.value; +} + +export function getHotValue() { + const hot = import.meta?.hot; + return hot?.data?.value; +} + +export function testHotApi() { + const hot = import.meta?.hot; + const result = { + ok: false, + hasHot: !!hot, + hasData: !!hot?.data, + hasAccept: typeof hot?.accept === "function", + hasDispose: typeof hot?.dispose === "function", + hasDecline: typeof hot?.decline === "function", + hasInvalidate: typeof hot?.invalidate === "function", + pruneIsFalse: hot?.prune === false, + }; + + try { + hot?.accept?.(() => {}); + hot?.dispose?.(() => {}); + hot?.decline?.(); + hot?.invalidate?.(); + result.ok = + result.hasHot && + result.hasData && + result.hasAccept && + result.hasDispose && + result.hasDecline && + result.hasInvalidate && + result.pruneIsFalse; + } catch (e) { + result.error = e?.message ?? String(e); + } + + return result; +} diff --git a/test-app/app/src/main/assets/app/hmrHotKeyExt.mjs b/test-app/app/src/main/assets/app/hmrHotKeyExt.mjs new file mode 100644 index 000000000..04383be81 --- /dev/null +++ b/test-app/app/src/main/assets/app/hmrHotKeyExt.mjs @@ -0,0 +1,51 @@ +export function getHotData() { + return import.meta?.hot?.data; +} + +export function setHotValue(value) { + const hot = import.meta?.hot; + if (!hot || !hot.data) { + throw new Error("import.meta.hot.data is not available"); + } + hot.data.value = value; + return hot.data.value; +} + +export function getHotValue() { + const hot = import.meta?.hot; + return hot?.data?.value; +} + +export function testHotApi() { + const hot = import.meta?.hot; + const result = { + ok: false, + hasHot: !!hot, + hasData: !!hot?.data, + hasAccept: typeof hot?.accept === "function", + hasDispose: typeof hot?.dispose === "function", + hasDecline: typeof hot?.decline === "function", + hasInvalidate: typeof hot?.invalidate === "function", + pruneIsFalse: hot?.prune === false, + }; + + try { + // accept([deps], cb?) — deps ignored, cb registered if provided + hot?.accept?.(() => {}); + hot?.dispose?.(() => {}); + hot?.decline?.(); + hot?.invalidate?.(); + result.ok = + result.hasHot && + result.hasData && + result.hasAccept && + result.hasDispose && + result.hasDecline && + result.hasInvalidate && + result.pruneIsFalse; + } catch (e) { + result.error = e?.message ?? String(e); + } + + return result; +} diff --git a/test-app/app/src/main/assets/app/tests/testESModules.mjs b/test-app/app/src/main/assets/app/tests/testESModules.mjs index 64bea7e90..6ae6984c5 100644 --- a/test-app/app/src/main/assets/app/tests/testESModules.mjs +++ b/test-app/app/src/main/assets/app/tests/testESModules.mjs @@ -150,6 +150,57 @@ async function runESModuleTests() { } catch (e) { recordFailure("Error testing Worker features", { error: e }); } + + console.log("\n--- Testing import.meta.hot and hot.data persistence ---"); + try { + // Import same base module under different extensions. The runtime canonicalizes + // the hot-data key by stripping common script extensions, so these should share + // a single hot.data object. + const modMjs = await import("~/hmrHotKeyExt.mjs"); + const modJs = await import("~/hmrHotKeyExt.js"); + + const apiMjs = modMjs?.testHotApi?.(); + const apiJs = modJs?.testHotApi?.(); + if (apiMjs?.ok && apiJs?.ok) { + recordPass("import.meta.hot API available"); + } else { + recordFailure("import.meta.hot API missing or invalid", { + details: [ + `mjs: ${JSON.stringify(apiMjs)}`, + `js: ${JSON.stringify(apiJs)}`, + ], + }); + } + + const dataMjs = modMjs?.getHotData?.(); + const dataJs = modJs?.getHotData?.(); + if (dataMjs && dataJs) { + // Share a value through hot.data from one module and read it from the other. + const token = `tok_${Date.now()}_${Math.random()}`; + modMjs?.setHotValue?.(token); + + const readBack = modJs?.getHotValue?.(); + if (readBack === token) { + recordPass("hot.data shared across .mjs/.js imports"); + } else { + recordFailure("hot.data not shared across .mjs/.js imports", { + details: [`expected=${token}`, `actual=${readBack}`], + }); + } + + if (dataMjs === dataJs) { + recordPass("hot.data object identity matches"); + } else { + recordFailure("hot.data object identity differs"); + } + } else { + recordFailure("hot.data is missing", { + details: [`dataMjs=${String(!!dataMjs)}`, `dataJs=${String(!!dataJs)}`], + }); + } + } catch (e) { + recordFailure("Error testing import.meta.hot", { error: e }); + } } catch (unexpectedError) { recordFailure("Unexpected ES module test harness failure", { error: unexpectedError, diff --git a/test-app/runtime/src/main/cpp/HMRSupport.cpp b/test-app/runtime/src/main/cpp/HMRSupport.cpp index 7665c1d16..602decf9c 100644 --- a/test-app/runtime/src/main/cpp/HMRSupport.cpp +++ b/test-app/runtime/src/main/cpp/HMRSupport.cpp @@ -16,6 +16,11 @@ static inline bool StartsWith(const std::string& s, const char* prefix) { return s.size() >= n && s.compare(0, n, prefix) == 0; } +static inline bool EndsWith(const std::string& s, const char* suffix) { + size_t n = strlen(suffix); + return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0; +} + // Per-module hot data and callbacks. Keyed by canonical module path (file path or URL). static std::unordered_map> g_hotData; static std::unordered_map>> g_hotAccept; @@ -76,6 +81,63 @@ void InitializeImportMetaHot(v8::Isolate* isolate, v8::HandleScope scope(isolate); + // Canonicalize key to ensure per-module hot.data persists across HMR URLs. + // Important: this must NOT affect the HTTP loader cache key; otherwise HMR fetches + // can collapse onto an already-evaluated module and no update occurs. + auto canonicalHotKey = [&](const std::string& in) -> std::string { + // Some loaders wrap HTTP module URLs as file://http(s)://... + std::string s = in; + if (StartsWith(s, "file://http://") || StartsWith(s, "file://https://")) { + s = s.substr(strlen("file://")); + } + + // Drop fragment + size_t hashPos = s.find('#'); + if (hashPos != std::string::npos) { + s = s.substr(0, hashPos); + } + + // Drop query (we want hot key stability) + size_t qPos = s.find('?'); + std::string noQuery = (qPos == std::string::npos) ? s : s.substr(0, qPos); + + // If it's an http(s) URL, normalize only the path portion below. + size_t schemePos = noQuery.find("://"); + size_t pathStart = (schemePos == std::string::npos) ? 0 : noQuery.find('/', schemePos + 3); + if (pathStart == std::string::npos) { + // No path; return without query + return noQuery; + } + + std::string origin = noQuery.substr(0, pathStart); + std::string path = noQuery.substr(pathStart); + + // Normalize NS HMR virtual module paths: + // /ns/m/__ns_hmr__// -> /ns/m/ + const char* hmrPrefix = "/ns/m/__ns_hmr__/"; + size_t hmrLen = strlen(hmrPrefix); + if (path.compare(0, hmrLen, hmrPrefix) == 0) { + size_t nextSlash = path.find('/', hmrLen); + if (nextSlash != std::string::npos) { + path = std::string("/ns/m/") + path.substr(nextSlash + 1); + } + } + + // Normalize common script extensions so `/foo` and `/foo.ts` share hot.data. + const char* exts[] = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}; + for (auto ext : exts) { + if (EndsWith(path, ext)) { + path = path.substr(0, path.size() - strlen(ext)); + break; + } + } + + // Also drop `.vue`? No — SFC endpoints should stay distinct. + return origin + path; + }; + + const std::string key = canonicalHotKey(modulePath); + auto makeKeyData = [&](const std::string& key) -> Local { return ArgConverter::ConvertToV8String(isolate, key); }; @@ -121,33 +183,38 @@ void InitializeImportMetaHot(v8::Isolate* isolate, Local hot = Object::New(isolate); hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "data"), - GetOrCreateHotData(isolate, modulePath)).Check(); + GetOrCreateHotData(isolate, key)).Check(); hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "prune"), v8::Boolean::New(isolate, false)).Check(); hot->CreateDataProperty( context, ArgConverter::ConvertToV8String(isolate, "accept"), - v8::Function::New(context, acceptCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); + v8::Function::New(context, acceptCb, makeKeyData(key)).ToLocalChecked()).Check(); hot->CreateDataProperty( context, ArgConverter::ConvertToV8String(isolate, "dispose"), - v8::Function::New(context, disposeCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); + v8::Function::New(context, disposeCb, makeKeyData(key)).ToLocalChecked()).Check(); hot->CreateDataProperty( context, ArgConverter::ConvertToV8String(isolate, "decline"), - v8::Function::New(context, declineCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); + v8::Function::New(context, declineCb, makeKeyData(key)).ToLocalChecked()).Check(); hot->CreateDataProperty( context, ArgConverter::ConvertToV8String(isolate, "invalidate"), - v8::Function::New(context, invalidateCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); + v8::Function::New(context, invalidateCb, makeKeyData(key)).ToLocalChecked()).Check(); importMeta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "hot"), hot).Check(); } // Drop fragments and normalize parameters for consistent registry keys. std::string CanonicalizeHttpUrlKey(const std::string& url) { - if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) { - return url; + // Some loaders wrap HTTP module URLs as file://http(s)://... + std::string normalizedUrl = url; + if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) { + normalizedUrl = normalizedUrl.substr(strlen("file://")); + } + if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) { + return normalizedUrl; } // Remove fragment - size_t hashPos = url.find('#'); - std::string noHash = (hashPos == std::string::npos) ? url : url.substr(0, hashPos); + size_t hashPos = normalizedUrl.find('#'); + std::string noHash = (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos); // Split into origin+path and query size_t qPos = noHash.find('?'); @@ -158,10 +225,13 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { // - /ns/rt/ -> /ns/rt // - /ns/core/ -> /ns/core size_t schemePos = originAndPath.find("://"); - if (schemePos != std::string::npos) { - size_t pathStart = originAndPath.find('/', schemePos + 3); - if (pathStart != std::string::npos) { - std::string pathOnly = originAndPath.substr(pathStart); + size_t pathStart = (schemePos == std::string::npos) ? std::string::npos : originAndPath.find('/', schemePos + 3); + if (pathStart == std::string::npos) { + // No path; preserve query as-is (fragment already removed). + return noHash; + } + { + std::string pathOnly = originAndPath.substr(pathStart); auto normalizeBridge = [&](const char* needle) { size_t nlen = strlen(needle); if (pathOnly.size() <= nlen) return false; @@ -180,12 +250,29 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { if (!normalizeBridge("/ns/rt")) { normalizeBridge("/ns/core"); } + } + + // IMPORTANT: This function is used as an HTTP module registry/cache key. + // For general-purpose HTTP module loading (public internet), the query string + // can be part of the module's identity (auth, content versioning, routing, etc). + // Therefore we only apply query normalization (sorting/dropping) for known + // NativeScript dev endpoints where `t`/`v`/`import` are purely cache busters. + { + std::string pathOnly = originAndPath.substr(pathStart); + const bool isDevEndpoint = + StartsWith(pathOnly, "/ns/") || + StartsWith(pathOnly, "/node_modules/.vite/") || + StartsWith(pathOnly, "/@id/") || + StartsWith(pathOnly, "/@fs/"); + if (!isDevEndpoint) { + // Preserve query as-is (fragment already removed). + return noHash; } } if (query.empty()) return originAndPath; - // Strip ?import markers and sort remaining query params for stability + // Strip ?import markers / cache busters and sort remaining query params for stability std::vector kept; size_t start = 0; while (start <= query.size()) { @@ -194,7 +281,7 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { if (!pair.empty()) { size_t eq = pair.find('='); std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); - if (!(name == "import")) kept.push_back(pair); + if (!(name == "import" || name == "t" || name == "v")) kept.push_back(pair); } if (amp == std::string::npos) break; start = amp + 1;