diff --git a/test-app/app/src/main/assets/app/esm/relative/query-entry.mjs b/test-app/app/src/main/assets/app/esm/relative/query-entry.mjs new file mode 100644 index 000000000..9b395abe8 --- /dev/null +++ b/test-app/app/src/main/assets/app/esm/relative/query-entry.mjs @@ -0,0 +1,8 @@ +import defaultValue, { relativeValue } from "./dependency.mjs?v=static"; + +export const viaDefault = defaultValue; +export const viaNamed = relativeValue; + +export function loadWithQuery() { + return import("./dependency.mjs?v=dynamic"); +} diff --git a/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js index 1d7e76e45..839a1674e 100644 --- a/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js +++ b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js @@ -73,6 +73,22 @@ describe("HTTP ESM Loader", function () { }); }); + // A query or fragment on a specifier that names a file is URL syntax + // the filesystem never sees. The same statement in a served module + // keeps it — see "query-bearing specifiers from a served referrer". + it("drops the query when a local import names a file", function (done) { + import("~/esm/relative/query-entry.mjs?v=entry").then(function (module) { + expect(module.viaDefault).toBe("relative-import-success"); + expect(module.viaNamed).toBe("relative-import-success"); + return module.loadWithQuery(); + }).then(function (dependency) { + expect(dependency.relativeValue).toBe("relative-import-success"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + it("should surface helpful errors for unresolved bare specifiers", function (done) { import("bare-spec-example").then(function (mod) { // A placeholder module default-exports a Proxy whose get trap @@ -93,6 +109,76 @@ describe("HTTP ESM Loader", function () { }); }); + // A served module's relative and root-absolute imports resolve against its + // URL, and a query on them is part of the resulting module's identity: + // `/esm/query.mjs?v=a` and `/esm/query.mjs` are two modules to the server, + // exactly as `/ns/asm?path=...` and `/ns/asm` are to a dev server. Every + // specifier shape must reach the server with its query intact. + describe("query-bearing specifiers from a served referrer", function () { + useHttpTimeout(); + + var nsModule = require("ns:module"); + var formsUrl = origin + "/esm/query-forms.mjs"; + + afterEach(function () { + nsModule.configureLoader({ importMap: { imports: {} } }); + }); + + it("keeps the query on static root-absolute and relative imports", function (done) { + withTimeout(import(formsUrl), 10000, "import " + formsUrl) + .then(function (mod) { + expect(mod.path).toBe("/esm/query.mjs"); + expect(mod.query).toContain("v=root-abs"); + expect(mod.relativeQuery).toContain("v=relative"); + // `export *` and `export { default }` name one URL, so + // they share one evaluated instance. + expect(mod.default.query).toContain("v=root-abs"); + expect(mod.default.evaluatedAt).toBe(mod.evaluatedAt); + done(); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + + it("keeps the query on dynamic root-absolute and relative imports", function (done) { + var forms; + withTimeout(import(formsUrl), 10000, "import " + formsUrl) + .then(function (mod) { + forms = mod; + return withTimeout(forms.loadRootAbs(), 10000, "dynamic root-absolute import"); + }) + .then(function (rootAbs) { + expect(rootAbs.path).toBe("/esm/query.mjs"); + expect(rootAbs.query).toContain("v=dyn-root"); + return withTimeout(forms.loadRelative(), 10000, "dynamic relative import"); + }) + .then(function (relative) { + expect(relative.path).toBe("/esm/query.mjs"); + expect(relative.query).toContain("v=dyn-rel"); + done(); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + + it("keeps the query through an import-map prefix entry", function (done) { + nsModule.configureLoader({ + importMap: { imports: { "ns-test-esm/": origin + "/esm/" } }, + }); + withTimeout(import("ns-test-esm/query.mjs?v=prefix"), 10000, "prefix-mapped import") + .then(function (mod) { + expect(mod.path).toBe("/esm/query.mjs"); + expect(mod.query).toContain("v=prefix"); + done(); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + }); + describe("HTTP Fetch Integration", function () { it("settles a local dynamic import issued from a background thread", function (done) { diff --git a/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java b/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java index b95fb4a78..38d59a899 100644 --- a/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java +++ b/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java @@ -154,6 +154,21 @@ private static void route(Socket socket, String path, String query) throws IOExc return; } + if ("/esm/query-forms.mjs".equals(path)) { + // Every specifier shape a served module can use to reach a sibling + // whose query is its identity. The server must receive each query + // intact: `/esm/query.mjs?v=x` and `/esm/query.mjs` are different + // modules to it, as `/ns/asm?path=...` and `/ns/asm` are to a dev + // server. + String body = "export * from \"/esm/query.mjs?v=root-abs\";\n" + + "export { default } from \"/esm/query.mjs?v=root-abs\";\n" + + "export { query as relativeQuery } from \"./query.mjs?v=relative\";\n" + + "export function loadRootAbs() { return import(\"/esm/query.mjs?v=dyn-root\"); }\n" + + "export function loadRelative() { return import(\"./query.mjs?v=dyn-rel\"); }\n"; + respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8)); + return; + } + if ("/esm/html-fallback.mjs".equals(path)) { // The SPA-fallback shape: an unknown path answered with the index // document, 200 OK. The module loader must reject it on MIME rather diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 28e59cd06..f83007769 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -1147,16 +1147,6 @@ static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec, spec.insert(6, "/"); } - // Query and fragment only mean something to a server, so a non-http - // specifier drops them before anything looks it up. Applied here, in the one - // seam both import forms go through, so `./x.js?v=1` names the same module - // whether it arrives as a static import or an import(). - if (!(StartsWith(spec, "http://") || StartsWith(spec, "https://"))) { - size_t cut = spec.find_first_of("?#"); - if (cut != std::string::npos) spec = spec.substr(0, cut); - if (spec.empty()) return result; - } - TNS_DEBUG(Esm, "[resolver][spec] %s", spec.c_str()); // The import map is consulted before any other resolution: bare specifiers @@ -1223,6 +1213,24 @@ static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec, } } + // A query or fragment is URL syntax, never part of a file name, so + // `import './x.js?v=1'` names x.js on disk. It is dropped only here, after + // every HTTP outcome has returned: for a served module the query is part of + // its identity (`/ns/asm?path=A` and `/ns/asm` are two modules to the + // server), and that holds for a root-absolute or relative specifier just as + // it does for an absolute URL. Applied in this one seam, so a static import + // and an import() of `./x.js?v=1` name the same file. + { + size_t cut = spec.find_first_of("?#"); + if (cut != std::string::npos) { + std::string stripped = spec.substr(0, cut); + TNS_DEBUG(Esm, "[resolver][strip-query] %s -> %s", spec.c_str(), + stripped.c_str()); + spec = stripped; + } + if (spec.empty()) return result; + } + // Build the filesystem candidates for this specifier shape. The specifier may // omit its extension or name a directory, so each candidate is probed with // Node-style extension and index fallbacks below. @@ -2533,21 +2541,10 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( return builtinScope.Escape(builtinResolver->GetPromise()); } + // The specifier reaches the shared seam verbatim. Whether its query is + // identity (a served module) or noise (a file) is the seam's decision, made + // the same way for a static import and an import(). std::string normalizedSpec = rawSpec; - // remove query/hash ONLY for non-HTTP specs - bool isHttpLike = - (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || - StartsWith(normalizedSpec, "https://"))); - if (!isHttpLike) { - size_t qpos = normalizedSpec.find_first_of("?#"); - if (qpos != std::string::npos) { - normalizedSpec = normalizedSpec.substr(0, qpos); - } - } - if (normalizedSpec != rawSpec) { - TNS_DEBUG(Esm, "[dyn-import][normalize] %s -> %s", rawSpec.c_str(), - normalizedSpec.c_str()); - } v8::EscapableHandleScope scope(isolate); @@ -2579,6 +2576,16 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( TNS_DEBUG(Esm, "[dyn-import][import-map] rewrite: %s -> %s", rawSpec.c_str(), normalizedSpec.c_str()); } + // A relative or root-absolute specifier from a served referrer resolves to + // a URL the specifier itself never spells out. Routing on that URL keeps + // such an import() on the async fetch path with its absolute-URL siblings. + if (dynamicResolution.kind == ModuleResolution::Kind::kHttp && + !dynamicResolution.url.empty() && + dynamicResolution.url != normalizedSpec) { + TNS_DEBUG(Esm, "[dyn-import][http-rel] %s -> %s", normalizedSpec.c_str(), + dynamicResolution.url.c_str()); + normalizedSpec = dynamicResolution.url; + } try { // ── Blob URL support (e.g. blob:nativescript/) ──