Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions test-app/app/src/main/assets/app/hmrHotKeyExt.js
Original file line number Diff line number Diff line change
@@ -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;
}
51 changes: 51 additions & 0 deletions test-app/app/src/main/assets/app/hmrHotKeyExt.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
51 changes: 51 additions & 0 deletions test-app/app/src/main/assets/app/tests/testESModules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
117 changes: 102 additions & 15 deletions test-app/runtime/src/main/cpp/HMRSupport.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string, v8::Global<v8::Object>> g_hotData;
static std::unordered_map<std::string, std::vector<v8::Global<v8::Function>>> g_hotAccept;
Expand Down Expand Up @@ -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__/<token>/<rest> -> /ns/m/<rest>
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<Value> {
return ArgConverter::ConvertToV8String(isolate, key);
};
Expand Down Expand Up @@ -121,33 +183,38 @@ void InitializeImportMetaHot(v8::Isolate* isolate,

Local<Object> 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('?');
Expand All @@ -158,10 +225,13 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) {
// - /ns/rt/<ver> -> /ns/rt
// - /ns/core/<ver> -> /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;
Expand All @@ -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<std::string> kept;
size_t start = 0;
while (start <= query.size()) {
Expand All @@ -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;
Expand Down
Loading