Skip to content

Commit 3cc3f6c

Browse files
committed
feat: support import.meta.hot.data for persistent objects across hmr updates
1 parent 1f983fe commit 3cc3f6c

File tree

4 files changed

+254
-15
lines changed

4 files changed

+254
-15
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export function getHotData() {
2+
return import.meta?.hot?.data;
3+
}
4+
5+
export function setHotValue(value) {
6+
const hot = import.meta?.hot;
7+
if (!hot || !hot.data) {
8+
throw new Error("import.meta.hot.data is not available");
9+
}
10+
hot.data.value = value;
11+
return hot.data.value;
12+
}
13+
14+
export function getHotValue() {
15+
const hot = import.meta?.hot;
16+
return hot?.data?.value;
17+
}
18+
19+
export function testHotApi() {
20+
const hot = import.meta?.hot;
21+
const result = {
22+
ok: false,
23+
hasHot: !!hot,
24+
hasData: !!hot?.data,
25+
hasAccept: typeof hot?.accept === "function",
26+
hasDispose: typeof hot?.dispose === "function",
27+
hasDecline: typeof hot?.decline === "function",
28+
hasInvalidate: typeof hot?.invalidate === "function",
29+
pruneIsFalse: hot?.prune === false,
30+
};
31+
32+
try {
33+
hot?.accept?.(() => {});
34+
hot?.dispose?.(() => {});
35+
hot?.decline?.();
36+
hot?.invalidate?.();
37+
result.ok =
38+
result.hasHot &&
39+
result.hasData &&
40+
result.hasAccept &&
41+
result.hasDispose &&
42+
result.hasDecline &&
43+
result.hasInvalidate &&
44+
result.pruneIsFalse;
45+
} catch (e) {
46+
result.error = e?.message ?? String(e);
47+
}
48+
49+
return result;
50+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export function getHotData() {
2+
return import.meta?.hot?.data;
3+
}
4+
5+
export function setHotValue(value) {
6+
const hot = import.meta?.hot;
7+
if (!hot || !hot.data) {
8+
throw new Error("import.meta.hot.data is not available");
9+
}
10+
hot.data.value = value;
11+
return hot.data.value;
12+
}
13+
14+
export function getHotValue() {
15+
const hot = import.meta?.hot;
16+
return hot?.data?.value;
17+
}
18+
19+
export function testHotApi() {
20+
const hot = import.meta?.hot;
21+
const result = {
22+
ok: false,
23+
hasHot: !!hot,
24+
hasData: !!hot?.data,
25+
hasAccept: typeof hot?.accept === "function",
26+
hasDispose: typeof hot?.dispose === "function",
27+
hasDecline: typeof hot?.decline === "function",
28+
hasInvalidate: typeof hot?.invalidate === "function",
29+
pruneIsFalse: hot?.prune === false,
30+
};
31+
32+
try {
33+
// accept([deps], cb?) — deps ignored, cb registered if provided
34+
hot?.accept?.(() => {});
35+
hot?.dispose?.(() => {});
36+
hot?.decline?.();
37+
hot?.invalidate?.();
38+
result.ok =
39+
result.hasHot &&
40+
result.hasData &&
41+
result.hasAccept &&
42+
result.hasDispose &&
43+
result.hasDecline &&
44+
result.hasInvalidate &&
45+
result.pruneIsFalse;
46+
} catch (e) {
47+
result.error = e?.message ?? String(e);
48+
}
49+
50+
return result;
51+
}

test-app/app/src/main/assets/app/tests/testESModules.mjs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,57 @@ async function runESModuleTests() {
150150
} catch (e) {
151151
recordFailure("Error testing Worker features", { error: e });
152152
}
153+
154+
console.log("\n--- Testing import.meta.hot and hot.data persistence ---");
155+
try {
156+
// Import same base module under different extensions. The runtime canonicalizes
157+
// the hot-data key by stripping common script extensions, so these should share
158+
// a single hot.data object.
159+
const modMjs = await import("~/hmrHotKeyExt.mjs");
160+
const modJs = await import("~/hmrHotKeyExt.js");
161+
162+
const apiMjs = modMjs?.testHotApi?.();
163+
const apiJs = modJs?.testHotApi?.();
164+
if (apiMjs?.ok && apiJs?.ok) {
165+
recordPass("import.meta.hot API available");
166+
} else {
167+
recordFailure("import.meta.hot API missing or invalid", {
168+
details: [
169+
`mjs: ${JSON.stringify(apiMjs)}`,
170+
`js: ${JSON.stringify(apiJs)}`,
171+
],
172+
});
173+
}
174+
175+
const dataMjs = modMjs?.getHotData?.();
176+
const dataJs = modJs?.getHotData?.();
177+
if (dataMjs && dataJs) {
178+
// Share a value through hot.data from one module and read it from the other.
179+
const token = `tok_${Date.now()}_${Math.random()}`;
180+
modMjs?.setHotValue?.(token);
181+
182+
const readBack = modJs?.getHotValue?.();
183+
if (readBack === token) {
184+
recordPass("hot.data shared across .mjs/.js imports");
185+
} else {
186+
recordFailure("hot.data not shared across .mjs/.js imports", {
187+
details: [`expected=${token}`, `actual=${readBack}`],
188+
});
189+
}
190+
191+
if (dataMjs === dataJs) {
192+
recordPass("hot.data object identity matches");
193+
} else {
194+
recordFailure("hot.data object identity differs");
195+
}
196+
} else {
197+
recordFailure("hot.data is missing", {
198+
details: [`dataMjs=${String(!!dataMjs)}`, `dataJs=${String(!!dataJs)}`],
199+
});
200+
}
201+
} catch (e) {
202+
recordFailure("Error testing import.meta.hot", { error: e });
203+
}
153204
} catch (unexpectedError) {
154205
recordFailure("Unexpected ES module test harness failure", {
155206
error: unexpectedError,

test-app/runtime/src/main/cpp/HMRSupport.cpp

Lines changed: 102 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ static inline bool StartsWith(const std::string& s, const char* prefix) {
1616
return s.size() >= n && s.compare(0, n, prefix) == 0;
1717
}
1818

19+
static inline bool EndsWith(const std::string& s, const char* suffix) {
20+
size_t n = strlen(suffix);
21+
return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0;
22+
}
23+
1924
// Per-module hot data and callbacks. Keyed by canonical module path (file path or URL).
2025
static std::unordered_map<std::string, v8::Global<v8::Object>> g_hotData;
2126
static std::unordered_map<std::string, std::vector<v8::Global<v8::Function>>> g_hotAccept;
@@ -76,6 +81,63 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
7681

7782
v8::HandleScope scope(isolate);
7883

84+
// Canonicalize key to ensure per-module hot.data persists across HMR URLs.
85+
// Important: this must NOT affect the HTTP loader cache key; otherwise HMR fetches
86+
// can collapse onto an already-evaluated module and no update occurs.
87+
auto canonicalHotKey = [&](const std::string& in) -> std::string {
88+
// Some loaders wrap HTTP module URLs as file://http(s)://...
89+
std::string s = in;
90+
if (StartsWith(s, "file://http://") || StartsWith(s, "file://https://")) {
91+
s = s.substr(strlen("file://"));
92+
}
93+
94+
// Drop fragment
95+
size_t hashPos = s.find('#');
96+
if (hashPos != std::string::npos) {
97+
s = s.substr(0, hashPos);
98+
}
99+
100+
// Drop query (we want hot key stability)
101+
size_t qPos = s.find('?');
102+
std::string noQuery = (qPos == std::string::npos) ? s : s.substr(0, qPos);
103+
104+
// If it's an http(s) URL, normalize only the path portion below.
105+
size_t schemePos = noQuery.find("://");
106+
size_t pathStart = (schemePos == std::string::npos) ? 0 : noQuery.find('/', schemePos + 3);
107+
if (pathStart == std::string::npos) {
108+
// No path; return without query
109+
return noQuery;
110+
}
111+
112+
std::string origin = noQuery.substr(0, pathStart);
113+
std::string path = noQuery.substr(pathStart);
114+
115+
// Normalize NS HMR virtual module paths:
116+
// /ns/m/__ns_hmr__/<token>/<rest> -> /ns/m/<rest>
117+
const char* hmrPrefix = "/ns/m/__ns_hmr__/";
118+
size_t hmrLen = strlen(hmrPrefix);
119+
if (path.compare(0, hmrLen, hmrPrefix) == 0) {
120+
size_t nextSlash = path.find('/', hmrLen);
121+
if (nextSlash != std::string::npos) {
122+
path = std::string("/ns/m/") + path.substr(nextSlash + 1);
123+
}
124+
}
125+
126+
// Normalize common script extensions so `/foo` and `/foo.ts` share hot.data.
127+
const char* exts[] = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"};
128+
for (auto ext : exts) {
129+
if (EndsWith(path, ext)) {
130+
path = path.substr(0, path.size() - strlen(ext));
131+
break;
132+
}
133+
}
134+
135+
// Also drop `.vue`? No — SFC endpoints should stay distinct.
136+
return origin + path;
137+
};
138+
139+
const std::string key = canonicalHotKey(modulePath);
140+
79141
auto makeKeyData = [&](const std::string& key) -> Local<Value> {
80142
return ArgConverter::ConvertToV8String(isolate, key);
81143
};
@@ -121,33 +183,38 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
121183

122184
Local<Object> hot = Object::New(isolate);
123185
hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "data"),
124-
GetOrCreateHotData(isolate, modulePath)).Check();
186+
GetOrCreateHotData(isolate, key)).Check();
125187
hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "prune"),
126188
v8::Boolean::New(isolate, false)).Check();
127189
hot->CreateDataProperty(
128190
context, ArgConverter::ConvertToV8String(isolate, "accept"),
129-
v8::Function::New(context, acceptCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
191+
v8::Function::New(context, acceptCb, makeKeyData(key)).ToLocalChecked()).Check();
130192
hot->CreateDataProperty(
131193
context, ArgConverter::ConvertToV8String(isolate, "dispose"),
132-
v8::Function::New(context, disposeCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
194+
v8::Function::New(context, disposeCb, makeKeyData(key)).ToLocalChecked()).Check();
133195
hot->CreateDataProperty(
134196
context, ArgConverter::ConvertToV8String(isolate, "decline"),
135-
v8::Function::New(context, declineCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
197+
v8::Function::New(context, declineCb, makeKeyData(key)).ToLocalChecked()).Check();
136198
hot->CreateDataProperty(
137199
context, ArgConverter::ConvertToV8String(isolate, "invalidate"),
138-
v8::Function::New(context, invalidateCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
200+
v8::Function::New(context, invalidateCb, makeKeyData(key)).ToLocalChecked()).Check();
139201

140202
importMeta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "hot"), hot).Check();
141203
}
142204

143205
// Drop fragments and normalize parameters for consistent registry keys.
144206
std::string CanonicalizeHttpUrlKey(const std::string& url) {
145-
if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) {
146-
return url;
207+
// Some loaders wrap HTTP module URLs as file://http(s)://...
208+
std::string normalizedUrl = url;
209+
if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) {
210+
normalizedUrl = normalizedUrl.substr(strlen("file://"));
211+
}
212+
if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) {
213+
return normalizedUrl;
147214
}
148215
// Remove fragment
149-
size_t hashPos = url.find('#');
150-
std::string noHash = (hashPos == std::string::npos) ? url : url.substr(0, hashPos);
216+
size_t hashPos = normalizedUrl.find('#');
217+
std::string noHash = (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos);
151218

152219
// Split into origin+path and query
153220
size_t qPos = noHash.find('?');
@@ -158,10 +225,13 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) {
158225
// - /ns/rt/<ver> -> /ns/rt
159226
// - /ns/core/<ver> -> /ns/core
160227
size_t schemePos = originAndPath.find("://");
161-
if (schemePos != std::string::npos) {
162-
size_t pathStart = originAndPath.find('/', schemePos + 3);
163-
if (pathStart != std::string::npos) {
164-
std::string pathOnly = originAndPath.substr(pathStart);
228+
size_t pathStart = (schemePos == std::string::npos) ? std::string::npos : originAndPath.find('/', schemePos + 3);
229+
if (pathStart == std::string::npos) {
230+
// No path; preserve query as-is (fragment already removed).
231+
return noHash;
232+
}
233+
{
234+
std::string pathOnly = originAndPath.substr(pathStart);
165235
auto normalizeBridge = [&](const char* needle) {
166236
size_t nlen = strlen(needle);
167237
if (pathOnly.size() <= nlen) return false;
@@ -180,12 +250,29 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) {
180250
if (!normalizeBridge("/ns/rt")) {
181251
normalizeBridge("/ns/core");
182252
}
253+
}
254+
255+
// IMPORTANT: This function is used as an HTTP module registry/cache key.
256+
// For general-purpose HTTP module loading (public internet), the query string
257+
// can be part of the module's identity (auth, content versioning, routing, etc).
258+
// Therefore we only apply query normalization (sorting/dropping) for known
259+
// NativeScript dev endpoints where `t`/`v`/`import` are purely cache busters.
260+
{
261+
std::string pathOnly = originAndPath.substr(pathStart);
262+
const bool isDevEndpoint =
263+
StartsWith(pathOnly, "/ns/") ||
264+
StartsWith(pathOnly, "/node_modules/.vite/") ||
265+
StartsWith(pathOnly, "/@id/") ||
266+
StartsWith(pathOnly, "/@fs/");
267+
if (!isDevEndpoint) {
268+
// Preserve query as-is (fragment already removed).
269+
return noHash;
183270
}
184271
}
185272

186273
if (query.empty()) return originAndPath;
187274

188-
// Strip ?import markers and sort remaining query params for stability
275+
// Strip ?import markers / cache busters and sort remaining query params for stability
189276
std::vector<std::string> kept;
190277
size_t start = 0;
191278
while (start <= query.size()) {
@@ -194,7 +281,7 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) {
194281
if (!pair.empty()) {
195282
size_t eq = pair.find('=');
196283
std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq);
197-
if (!(name == "import")) kept.push_back(pair);
284+
if (!(name == "import" || name == "t" || name == "v")) kept.push_back(pair);
198285
}
199286
if (amp == std::string::npos) break;
200287
start = amp + 1;

0 commit comments

Comments
 (0)