Skip to content

Commit ee4a8d3

Browse files
committed
refactor(module)!: reject missing bare import() specifiers instead of placeholding
The optional-module placeholder now serves the require() path only. ESM has a standards answer that CommonJS lacks — a dynamic-import failure is a catchable rejection, so 'try { await import(x) } catch {}' covers optional dependencies at the call site — and in a dev session a bare specifier the import map doesn't cover is a configuration bug that the placeholder was converting into a lazily-throwing proxy instead of a loud resolver failure. IsLikelyOptionalModule loses its header declaration and becomes static to ModuleInternal.mm with its require()-only role documented. The specs in NodeBuiltinsAndOptionalModulesTests.mjs pin the new contract: any missing bare specifier rejects with 'Cannot find module', package-shaped or not. The require()-side restore stays pending review, per docs/knowledge/optional-module-placeholder.md. If real-world dev-session testing surfaces a case that genuinely needs the import()-side placeholder (a static import of an optional package in a file-based ESM app), this commit is the isolated revert target.
1 parent fd616e3 commit ee4a8d3

5 files changed

Lines changed: 39 additions & 94 deletions

File tree

‎NativeScript/runtime/ModuleInternal.h‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,6 @@
66

77
namespace tns {
88

9-
// Heuristic: does this bare specifier look like an npm-style package name
10-
// (as opposed to an explicit, extension-qualified file reference)? Shared by
11-
// both the CommonJS require() path (ModuleInternal.mm) and the ESM
12-
// import()/resolver path (ModuleInternalCallbacks.mm) so the two can't drift
13-
// out of sync with each other. See the definition in ModuleInternal.mm for
14-
// the full rationale.
15-
bool IsLikelyOptionalModule(const std::string& moduleName);
16-
179
class ModuleInternal {
1810
public:
1911
ModuleInternal(v8::Local<v8::Context> context);

‎NativeScript/runtime/ModuleInternal.mm‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919

2020
namespace tns {
2121

22-
// Shared by both the require() path (this file) and the import()/ESM resolver path
23-
// (ModuleInternalCallbacks.mm) — see the declaration in ModuleInternal.h. Keeping a single
24-
// definition means a boundary fix like the extension carve-out below automatically applies
25-
// to both call sites instead of only the one that happened to get touched.
26-
bool IsLikelyOptionalModule(const std::string& moduleName) {
22+
// require()-path policy only: import() rejects a missing bare specifier outright
23+
// (ESM optionality is `try { await import(x) } catch {}` at the call site, and in
24+
// dev sessions a bare specifier the import map doesn't cover is a config bug that
25+
// must fail loudly — see docs/knowledge/hmr-simplification-pass.md §2).
26+
static bool IsLikelyOptionalModule(const std::string& moduleName) {
2727
// Node built-ins are handled by their own dedicated resolution path; never treat them as
2828
// an optional external module.
2929
if (moduleName.rfind("node:", 0) == 0) {

‎NativeScript/runtime/ModuleInternalCallbacks.mm‎

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@
2828

2929
namespace tns {
3030

31-
// IsLikelyOptionalModule is shared with the require() path — declared in
32-
// ModuleInternal.h, defined in ModuleInternal.mm.
3331
// Helper function to check if a module name is a Node.js built-in module
3432
static bool IsNodeBuiltinModule(const std::string& moduleName) {
3533
return moduleName.rfind("node:", 0) == 0;
@@ -2333,44 +2331,6 @@ static bool IsDocumentsPath(const std::string& path) {
23332331
std::string msg = "Cannot find module '" + spec + "' (failed to create in-memory polyfill)";
23342332
isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg)));
23352333
return v8::MaybeLocal<v8::Module>();
2336-
} else if (IsLikelyOptionalModule(spec)) {
2337-
// Treat bare specifiers as optional modules with an in-memory placeholder ES module
2338-
// that throws on property access. This avoids bundle writes in iOS release builds.
2339-
2340-
std::string key = std::string("optional:") + spec;
2341-
auto itExisting = g_moduleRegistry.find(key);
2342-
if (itExisting != g_moduleRegistry.end()) {
2343-
v8::Local<v8::Module> existing = itExisting->second.Get(isolate);
2344-
if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) {
2345-
return v8::MaybeLocal<v8::Module>(existing);
2346-
}
2347-
RemoveModuleFromRegistry(key);
2348-
}
2349-
2350-
std::string placeholderContent = "const error = new Error(\"Module '" + spec +
2351-
"' is not available. This is an optional module.\");\n"
2352-
"const proxy = new Proxy({}, {\n"
2353-
" get: function(target, prop) { throw error; },\n"
2354-
" set: function(target, prop, value) { throw error; },\n"
2355-
" has: function(target, prop) { return false; },\n"
2356-
" ownKeys: function(target) { return []; },\n"
2357-
" getPrototypeOf: function(target) { return null; }\n"
2358-
"});\n"
2359-
"export default proxy;\n";
2360-
2361-
v8::MaybeLocal<v8::Module> m =
2362-
CompileModuleForResolveRegisterOnly(isolate, context, placeholderContent, key);
2363-
if (!m.IsEmpty()) {
2364-
v8::Local<v8::Module> mod;
2365-
if (m.ToLocal(&mod)) {
2366-
return m;
2367-
}
2368-
}
2369-
2370-
std::string msg =
2371-
"Cannot find module '" + spec + "' (failed to create in-memory optional placeholder)";
2372-
isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg)));
2373-
return v8::MaybeLocal<v8::Module>();
23742334
} else {
23752335
std::string msg = "Cannot find module '" + spec + "' (tried " + absPath + ")";
23762336
isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg)));

‎TestRunner/app/tests/NodeBuiltinsAndOptionalModulesTests.mjs‎

Lines changed: 23 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,52 +17,34 @@ describe("Node built-in and optional module resolution", function () {
1717
expect(u.protocol).toBe("file:");
1818
});
1919

20-
it("creates an in-memory placeholder for likely-optional modules", async function () {
21-
// Use a name that IsLikelyOptionalModule will treat as optional (no slashes, no extension).
22-
const mod = await import("__ns_optional_test_module__");
23-
const modAgain = await import("__ns_optional_test_module__");
24-
25-
expect(mod).toBeDefined();
26-
expect(modAgain).toBe(mod);
27-
expect(typeof mod.default).toBe("object");
28-
29-
let threw = false;
30-
try {
31-
// Any property access should throw according to the placeholder implementation.
32-
// eslint-disable-next-line no-unused-expressions
33-
mod.default.someProperty;
34-
} catch (e) {
35-
threw = true;
36-
}
37-
expect(threw).toBe(true);
38-
});
39-
40-
// IsLikelyOptionalModule is shared between require() (CommonJS) and this ESM resolver
41-
// (see ModuleInternal.h) so the boundary can't drift apart between the two paths. The
42-
// require()-side cases live in shared/Require/index.js; these mirror them for import()
43-
// to confirm the two stay in parity.
44-
it("still creates a placeholder for a dotted-but-not-extension bare specifier", async function () {
45-
// A dot that isn't a recognized file extension (e.g. an npm name like "lodash.debounce")
46-
// must still be treated as optional, not as an explicit missing file.
47-
const mod = await import("__ns_optional_test_module.dotted__");
48-
49-
expect(mod).toBeDefined();
50-
expect(typeof mod.default).toBe("object");
20+
// The optional-module placeholder is require()-path policy only (its specs live in
21+
// shared/Require/index.js, pending review — docs/knowledge/optional-module-placeholder.md).
22+
// import() deliberately diverges: a missing bare specifier rejects, package-shaped or not,
23+
// so a dev-session import-map miss fails loudly instead of resolving to a lazily-throwing
24+
// proxy. ESM callers that want optionality can `try { await import(x) } catch {}`.
25+
it("rejects a missing bare specifier instead of resolving a placeholder", async function () {
26+
const names = [
27+
"__ns_optional_test_module__",
28+
// A dot that isn't a recognized file extension (e.g. an npm name shaped like
29+
// "lodash.debounce") gets no special treatment either.
30+
"__ns_optional_test_module.dotted__",
31+
];
5132

52-
let threw = false;
53-
try {
54-
// eslint-disable-next-line no-unused-expressions
55-
mod.default.someProperty;
56-
} catch (e) {
57-
threw = true;
33+
for (const name of names) {
34+
let error = null;
35+
try {
36+
await import(name);
37+
} catch (e) {
38+
error = e;
39+
}
40+
expect(error).not.toBe(null);
41+
expect(String(error)).toContain("Cannot find module");
5842
}
59-
expect(threw).toBe(true);
6043
});
6144

6245
it("rejects immediately for a missing bare specifier that carries an explicit file extension", async function () {
63-
// Mirrors the require()-side "video.js" case: a bare specifier with a recognized file
64-
// extension is an explicit (here, missing) file reference, not an optional dependency,
65-
// so import() must reject instead of resolving to a lazily-throwing placeholder.
46+
// Extension-qualified names ("video.js") resolve through the filesystem-candidate
47+
// walk rather than the bare-specifier path; pin that they reject the same way.
6648
const names = [
6749
"__ns_missing_import_test__.js",
6850
"__ns_missing_import_test__.json",

‎docs/knowledge/optional-module-placeholder.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ deliberately reinstates code that #412 removed. If the removal was intentional,
55
this document is the argument to overturn — delete the machinery *and* the test
66
and doc that still describe it, in one change.
77

8+
**Update (2026-07-29): the import()-side branch was removed again, this time
9+
deliberately.** The simplification pass
10+
([hmr-simplification-pass.md](hmr-simplification-pass.md) §2) split the
11+
question in two: on the ESM side a missing bare specifier now rejects
12+
outright — optionality is `try { await import(x) } catch {}` at the call
13+
site, and in dev sessions an import-map miss must fail loudly — while the
14+
require() side below, where the placeholder's lazy-throw contract is
15+
app-visible shipped behavior, stands exactly as this document describes and
16+
still needs Ed's call. The decision rule applied: remove first, and let a
17+
real-world reproduction argue it back in with evidence.
18+
819
## What was restored
920

1021
`feat!: upgrade V8 to 14.9.207.39` (0e89474) deleted the whole optional-module

0 commit comments

Comments
 (0)