Skip to content

Conversation

@NathanWalker
Copy link
Contributor

@NathanWalker NathanWalker commented Nov 6, 2025

This pull request introduces significant improvements to the NativeScript runtime, focusing on better support for ES modules, Hot Module Replacement (HMR), and developer ergonomics. The key changes include the addition of a new HMR support module, centralized development flags, enhanced path normalization for module resolution, and stricter handling of HTTP-based module imports. These updates help improve module loading reliability, enable minimal HMR features, and provide more consistent developer experience.

ESM/HMR Support Enhancements:

  • Added new HMRSupport.h and HMRSupport.mm modules providing minimal HMR (import.meta.hot) support, including per-module hot data, accept/dispose callback registration, and helpers for dev HTTP ESM loaders. [1] [2]
  • Introduced centralized development/runtime flags in DevFlags.h and DevFlags.mm (e.g., IsScriptLoadingLogEnabled) to control verbose logging and other dev features via package.json. [1] [2]

Module Resolution Improvements:

  • Implemented NormalizePath helper to standardize file system paths for consistent lookups in the module registry, reducing path-related bugs during module loading.
  • Updated module loading logic to use canonical paths and improved handling of ES module loading errors in debug mode to prevent unnecessary app termination. [1] [2]

HTTP Module Import Handling:

  • Added guards to prevent usage of require() for HTTP(S) modules, enforcing dynamic import() for such cases and providing clear error messages.
  • Improved canonicalization of HTTP URLs for module registry keys, ensuring stable and predictable module resolution during development and HMR.

…k to allow continued development during debug
With Vite, we just include inspector_modules in the bundle when running in debug mode so no need to load separate file. Other bundlers can do the same now.
@NathanWalker NathanWalker changed the title feat: http loaded es module realms and HMR DX enrichments feat: http loaded es module realms + HMR DX enrichments Nov 6, 2025
@NathanWalker NathanWalker marked this pull request as ready for review November 6, 2025 07:19
@NathanWalker NathanWalker requested a review from Copilot November 6, 2025 07:19
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds HTTP ESM (ECMAScript Module) loader functionality with Hot Module Replacement (HMR) support to the NativeScript iOS runtime. This enables the runtime to fetch and compile ES modules from a development server over HTTP during development, supporting features like live reload and hot module replacement.

Key Changes

  • HTTP-based ES module loading for development builds
  • HMR infrastructure with import.meta.hot support
  • Module registry enhancements for cache management and fallback handling
  • Comprehensive test suite for HTTP ESM loader functionality
  • App Transport Security configuration updates for HTTP requests

Reviewed Changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
package.json Version bump to 9.0.0-alpha.9
TestRunner/app/tests/index.js Added HttpEsmLoaderTests to test suite
TestRunner/app/tests/HttpEsmLoaderTests.js New test suite for HTTP ESM loader features
TestRunner/app/tests/esm/*.mjs Test fixtures for ESM loading scenarios
TestRunner/Info.plist Added NSAppTransportSecurity to allow HTTP requests
NativeScript/runtime/Runtime.mm Integrated HMR support headers and import.meta.hot initialization
NativeScript/runtime/ModuleInternalCallbacks.mm Core HTTP ESM loader implementation with module resolution
NativeScript/runtime/ModuleInternal.mm Path normalization and ESM loading enhancements
NativeScript/runtime/HMRSupport.{h,mm} New HMR support module with HTTP fetching utilities
NativeScript/runtime/DevFlags.{h,mm} New development flags module for logging control
NativeScript/NativeScript-Prefix.pch Version update to match package.json

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +33 to +35
#include "HMRSupport.mm"
#include "DevFlags.h"
#include "DevFlags.mm"
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including .mm implementation files directly in Runtime.mm violates the principle of separate compilation and can lead to multiple definition errors. Implementation files should be compiled separately and linked, not included. Remove lines 33 and 35, and ensure HMRSupport.mm and DevFlags.mm are added to the project's build targets instead.

Suggested change
#include "HMRSupport.mm"
#include "DevFlags.h"
#include "DevFlags.mm"
#include "DevFlags.h"

Copilot uses AI. Check for mistakes.
Comment on lines +175 to +185
// Helper function to create a test dev server module (if dev server is running)
function createTestDevModule() {
return `
// Test ES module for HTTP ESM loader
export const testValue = "http-esm-loaded";
export default function testFunction() {
return "HTTP ESM loader working";
}
export { testFunction as namedExport };
`;
}
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper function createTestDevModule at lines 176-185 is defined but never called or used in any test. This is dead code that should be removed to reduce clutter and prevent confusion.

Suggested change
// Helper function to create a test dev server module (if dev server is running)
function createTestDevModule() {
return `
// Test ES module for HTTP ESM loader
export const testValue = "http-esm-loaded";
export default function testFunction() {
return "HTTP ESM loader working";
}
export { testFunction as namedExport };
`;
}

Copilot uses AI. Check for mistakes.
Comment on lines +688 to +706
// Normalize malformed HTTP(S) schemes that sometimes appear as 'http:/host' (single slash)
// due to upstream path joins or standardization. This ensures our HTTP loader fast-path
// is used and avoids filesystem fallback attempts like '/app/http:/host'.
if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) {
spec.insert(5, "/"); // http:/ -> http://
} else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) {
spec.insert(6, "/"); // https:/ -> https://
}

if (IsScriptLoadingLogEnabled()) {
Log(@"[resolver][spec] %s", spec.c_str());
}

// Normalize '@/' alias to '/src/' for static imports (mirrors client dynamic import normalization)
if (spec.rfind("@/", 0) == 0) {
std::string orig = spec;
spec = std::string("/src/") + spec.substr(2);
if (IsScriptLoadingLogEnabled()) {
Log(@"[resolver][normalize] %@ -> %@", [NSString stringWithUTF8String:orig.c_str()], [NSString stringWithUTF8String:spec.c_str()]);
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This normalization logic mutates the input spec parameter. Consider storing the result in a separate variable to make the function side-effect-free and easier to reason about. For example: std::string normalizedSpec = spec; and then mutate normalizedSpec.

Suggested change
// Normalize malformed HTTP(S) schemes that sometimes appear as 'http:/host' (single slash)
// due to upstream path joins or standardization. This ensures our HTTP loader fast-path
// is used and avoids filesystem fallback attempts like '/app/http:/host'.
if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) {
spec.insert(5, "/"); // http:/ -> http://
} else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) {
spec.insert(6, "/"); // https:/ -> https://
}
if (IsScriptLoadingLogEnabled()) {
Log(@"[resolver][spec] %s", spec.c_str());
}
// Normalize '@/' alias to '/src/' for static imports (mirrors client dynamic import normalization)
if (spec.rfind("@/", 0) == 0) {
std::string orig = spec;
spec = std::string("/src/") + spec.substr(2);
if (IsScriptLoadingLogEnabled()) {
Log(@"[resolver][normalize] %@ -> %@", [NSString stringWithUTF8String:orig.c_str()], [NSString stringWithUTF8String:spec.c_str()]);
// Use a separate variable for normalization to avoid mutating the input spec
std::string normalizedSpec = spec;
// Normalize malformed HTTP(S) schemes that sometimes appear as 'http:/host' (single slash)
// due to upstream path joins or standardization. This ensures our HTTP loader fast-path
// is used and avoids filesystem fallback attempts like '/app/http:/host'.
if (normalizedSpec.rfind("http:/", 0) == 0 && normalizedSpec.rfind("http://", 0) != 0) {
normalizedSpec.insert(5, "/"); // http:/ -> http://
} else if (normalizedSpec.rfind("https:/", 0) == 0 && normalizedSpec.rfind("https://", 0) != 0) {
normalizedSpec.insert(6, "/"); // https:/ -> https://
}
if (IsScriptLoadingLogEnabled()) {
Log(@"[resolver][spec] %s", normalizedSpec.c_str());
}
// Normalize '@/' alias to '/src/' for static imports (mirrors client dynamic import normalization)
if (normalizedSpec.rfind("@/", 0) == 0) {
std::string orig = normalizedSpec;
normalizedSpec = std::string("/src/") + normalizedSpec.substr(2);
if (IsScriptLoadingLogEnabled()) {
Log(@"[resolver][normalize] %@ -> %@", [NSString stringWithUTF8String:orig.c_str()], [NSString stringWithUTF8String:normalizedSpec.c_str()]);

Copilot uses AI. Check for mistakes.
g_moduleReentryParents;
static thread_local std::unordered_map<std::string, std::string> g_modulePrimaryImporters;
static thread_local std::unordered_set<std::string> g_modulesInFlight;
static thread_local std::unordered_set<std::string> g_modulesPendingReset;
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 256 for kMaxModuleReentryCount at line 424 lacks justification. Add a comment explaining why 256 was chosen as the threshold for detecting circular dependencies and what happens when this limit is exceeded.

Suggested change
static thread_local std::unordered_set<std::string> g_modulesPendingReset;
static thread_local std::unordered_set<std::string> g_modulesPendingReset;
// The threshold for detecting circular dependencies during module resolution.
// 256 was chosen as a high enough value to allow deep but legitimate module graphs,
// but low enough to catch runaway recursion or infinite circular imports.
// If a module is re-entered more than this limit, module loading is aborted and
// an error is reported to prevent stack overflow or infinite loops.

Copilot uses AI. Check for mistakes.
gatingDisabled = [gatingFlag boolValue];
}
}
static std::atomic<size_t> g_hmrModuleGatedCount {0};
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable g_hmrModuleGatedCount at line 1462 is declared but never read or used for any diagnostic or operational purpose. If this is intended for future metrics collection, add a TODO comment. Otherwise, remove it to reduce code complexity.

Suggested change
static std::atomic<size_t> g_hmrModuleGatedCount {0};

Copilot uses AI. Check for mistakes.
Comment on lines +276 to +277
cfg.timeoutIntervalForRequest = 5.0;
cfg.timeoutIntervalForResource = 5.0;
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTTP timeout values of 5 seconds (lines 276-277) are hardcoded. Consider making these configurable via RuntimeConfig or app settings to allow developers to adjust timeouts based on their network conditions and dev server performance.

Copilot uses AI. Check for mistakes.
});

it("should handle network timeouts", function(done) {
// Attempt to import from an unreachable address to test timeout
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the TEST-NET-1 address 192.0.2.1 (RFC 5737) at line 116 is a good practice for documentation and testing. However, add a comment explaining that this is an RFC 5737 documentation address intentionally used to trigger timeout scenarios, which would help future maintainers understand the test's purpose.

Suggested change
// Attempt to import from an unreachable address to test timeout
// Attempt to import from an unreachable address to test timeout
// 192.0.2.1 is a TEST-NET-1 address reserved by RFC 5737 for documentation and testing purposes.
// It is intentionally used here to trigger a network timeout scenario.

Copilot uses AI. Check for mistakes.
Comment on lines +1202 to +1203
if (EndsWith(noExt, ".ts") || EndsWith(noExt, ".js")) {
noExt = noExt.substr(0, noExt.size() - 3);
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic at lines 1202-1204 strips exactly 3 characters assuming .ts or .js extensions, but doesn't handle other common extensions like .tsx, .jsx, .mts, or .cts which have different lengths. This could lead to incorrect path resolution for these file types. Consider using a more robust extension-stripping approach that handles variable-length extensions.

Suggested change
if (EndsWith(noExt, ".ts") || EndsWith(noExt, ".js")) {
noExt = noExt.substr(0, noExt.size() - 3);
// Handle variable-length extensions: .ts, .js, .tsx, .jsx, .mts, .cts
const std::vector<std::string> knownExts = {".ts", ".js", ".tsx", ".jsx", ".mts", ".cts"};
for (const auto& ext : knownExts) {
if (EndsWith(noExt, ext)) {
noExt = noExt.substr(0, noExt.size() - ext.size());
break;
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants