Skip to content

Commit 5e391ab

Browse files
committed
feat: support import.meta.hot.data for persistent objects across hmr updates
1 parent 73332e1 commit 5e391ab

2 files changed

Lines changed: 121 additions & 25 deletions

File tree

NativeScript/runtime/HMRSupport.h

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,33 @@ void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local<
3535
std::vector<v8::Local<v8::Function>> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key);
3636
std::vector<v8::Local<v8::Function>> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key);
3737

38-
// Attach a minimal import.meta.hot object to the provided import.meta object.
39-
// The modulePath should be the canonical path used to key callback/data maps.
38+
// `import.meta.hot` implementation
39+
// Provides:
40+
// - `hot.data` (per-module persistent object across HMR updates)
41+
// - `hot.accept(...)` (deps argument currently ignored; registers callback if provided)
42+
// - `hot.dispose(cb)` (registers disposer)
43+
// - `hot.decline()` / `hot.invalidate()` (currently no-ops)
44+
// - `hot.prune` (currently always false)
45+
//
46+
// Notes/limitations:
47+
// - Event APIs (`hot.on/off`), messaging (`hot.send`), and status handling are not implemented.
48+
// - `modulePath` is used to derive the per-module key for `hot.data` and callbacks.
4049
void InitializeImportMetaHot(v8::Isolate* isolate,
4150
v8::Local<v8::Context> context,
4251
v8::Local<v8::Object> importMeta,
4352
const std::string& modulePath);
4453

4554
// ─────────────────────────────────────────────────────────────
46-
// Dev HTTP loader helpers (used during HMR only)
47-
// These are isolated here so ModuleInternalCallbacks stays lean.
55+
// HTTP loader helpers (used by dev/HMR and general-purpose HTTP module loading)
4856
//
49-
// Normalize HTTP(S) URLs for module registry keys.
50-
// - Preserves versioning params for SFC endpoints (/@ns/sfc, /@ns/asm)
51-
// - Drops cache-busting segments for /@ns/rt and /@ns/core
52-
// - Drops query params for general app modules (/@ns/m)
57+
// Normalize an HTTP(S) URL into a stable module registry/cache key.
58+
// - Always strips URL fragments.
59+
// - For NativeScript dev endpoints, normalizes known cache busters (e.g. t/v/import)
60+
// and normalizes some versioned bridge paths.
61+
// - For non-dev/public URLs, preserves the full query string as part of the cache key.
5362
std::string CanonicalizeHttpUrlKey(const std::string& url);
5463

55-
// Minimal text fetch for dev HTTP ESM loader. Returns true on 2xx with non-empty body.
64+
// Minimal text fetch for HTTP ESM loader. Returns true on 2xx with non-empty body.
5665
// - out: response body
5766
// - contentType: Content-Type header if present
5867
// - status: HTTP status code

NativeScript/runtime/HMRSupport.mm

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

22+
static inline bool EndsWith(const std::string& s, const char* suffix) {
23+
size_t n = strlen(suffix);
24+
return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0;
25+
}
26+
2227
// Per-module hot data and callbacks. Keyed by canonical module path.
2328
static std::unordered_map<std::string, v8::Global<v8::Object>> g_hotData;
2429
static std::unordered_map<std::string, std::vector<v8::Global<v8::Function>>> g_hotAccept;
@@ -82,9 +87,67 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
8287
// Ensure context scope for property creation
8388
v8::HandleScope scope(isolate);
8489

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

90153
// accept([deps], cb?) — we register cb if provided; deps ignored for now
@@ -134,22 +197,22 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
134197
Local<Object> hot = Object::New(isolate);
135198
// Stable flags
136199
hot->CreateDataProperty(context, tns::ToV8String(isolate, "data"),
137-
GetOrCreateHotData(isolate, modulePath)).Check();
200+
GetOrCreateHotData(isolate, key)).Check();
138201
hot->CreateDataProperty(context, tns::ToV8String(isolate, "prune"),
139202
v8::Boolean::New(isolate, false)).Check();
140203
// Methods
141204
hot->CreateDataProperty(
142205
context, tns::ToV8String(isolate, "accept"),
143-
v8::Function::New(context, acceptCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
206+
v8::Function::New(context, acceptCb, makeKeyData(key)).ToLocalChecked()).Check();
144207
hot->CreateDataProperty(
145208
context, tns::ToV8String(isolate, "dispose"),
146-
v8::Function::New(context, disposeCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
209+
v8::Function::New(context, disposeCb, makeKeyData(key)).ToLocalChecked()).Check();
147210
hot->CreateDataProperty(
148211
context, tns::ToV8String(isolate, "decline"),
149-
v8::Function::New(context, declineCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
212+
v8::Function::New(context, declineCb, makeKeyData(key)).ToLocalChecked()).Check();
150213
hot->CreateDataProperty(
151214
context, tns::ToV8String(isolate, "invalidate"),
152-
v8::Function::New(context, invalidateCb, makeKeyData(modulePath)).ToLocalChecked()).Check();
215+
v8::Function::New(context, invalidateCb, makeKeyData(key)).ToLocalChecked()).Check();
153216

154217
// Attach to import.meta
155218
importMeta->CreateDataProperty(
@@ -158,15 +221,20 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
158221
}
159222

160223
// ─────────────────────────────────────────────────────────────
161-
// Dev HTTP loader helpers
224+
// HTTP loader helpers
162225

163226
std::string CanonicalizeHttpUrlKey(const std::string& url) {
164-
if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) {
165-
return url;
227+
// Some loaders wrap HTTP module URLs as file://http(s)://...
228+
std::string normalizedUrl = url;
229+
if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) {
230+
normalizedUrl = normalizedUrl.substr(strlen("file://"));
231+
}
232+
if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) {
233+
return normalizedUrl;
166234
}
167235
// Drop fragment entirely
168-
size_t hashPos = url.find('#');
169-
std::string noHash = (hashPos == std::string::npos) ? url : url.substr(0, hashPos);
236+
size_t hashPos = normalizedUrl.find('#');
237+
std::string noHash = (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos);
170238

171239
// Locate path start and query start
172240
size_t schemePos = noHash.find("://");
@@ -184,10 +252,10 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
184252
std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos);
185253
std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1);
186254

187-
// Normalize bridge endpoints to keep a single realm across HMR updates:
255+
// Normalize bridge endpoints to keep a single realm across reloads:
188256
// - /ns/rt/<ver> -> /ns/rt
189257
// - /ns/core/<ver> -> /ns/core
190-
// Preserve query params (e.g. /ns/core?p=...) as part of module identity.
258+
// Preserve query params (e.g. /ns/core?p=...), except for internal cache-busters (import, t, v), as part of module identity.
191259
{
192260
std::string pathOnly = originAndPath.substr(pathStart);
193261
auto normalizeBridge = [&](const char* needle) {
@@ -213,9 +281,27 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
213281
normalizeBridge("/ns/core");
214282
}
215283

284+
// IMPORTANT: This function is used as an HTTP module registry/cache key.
285+
// For general-purpose HTTP module loading (public internet), the query string
286+
// can be part of the module's identity (auth, content versioning, routing, etc).
287+
// Therefore we only apply query normalization (sorting/dropping) for known
288+
// NativeScript dev endpoints where `t`/`v`/`import` are purely cache busters.
289+
{
290+
std::string pathOnly = originAndPath.substr(pathStart);
291+
const bool isDevEndpoint =
292+
StartsWith(pathOnly, "/ns/") ||
293+
StartsWith(pathOnly, "/node_modules/.vite/") ||
294+
StartsWith(pathOnly, "/@id/") ||
295+
StartsWith(pathOnly, "/@fs/");
296+
if (!isDevEndpoint) {
297+
// Preserve query as-is (fragment already removed).
298+
return noHash;
299+
}
300+
}
301+
216302
if (query.empty()) return originAndPath;
217303

218-
// Keep all params except Vite's import marker; sort for stability.
304+
// Keep all params except typical import markers or t/v cache busters; sort for stability.
219305
std::vector<std::string> kept;
220306
size_t start = 0;
221307
while (start <= query.size()) {
@@ -224,7 +310,8 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
224310
if (!pair.empty()) {
225311
size_t eq = pair.find('=');
226312
std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq);
227-
if (!(name == "import")) kept.push_back(pair);
313+
// Drop import marker and common cache-busting stamps.
314+
if (!(name == "import" || name == "t" || name == "v")) kept.push_back(pair);
228315
}
229316
if (amp == std::string::npos) break;
230317
start = amp + 1;

0 commit comments

Comments
 (0)