From 5d89c62b8febe2cd610f07357d17082b0321a260 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sat, 5 Sep 2026 11:00:17 -0700 Subject: [PATCH] fix: keep the query on relative and root-absolute imports from served modules ResolveSpecifierToPath dropped `?query#fragment` from every non-http specifier before consulting the import map or the referrer. A module served over HTTP that imports `/ns/asm?path=%2Fsrc%2FHome.vue` therefore resolved to `http://host/ns/asm`, which the Vite dev server answers with 400 because the query is the module's identity. Any root-relative or relative `/ns/...` specifier carrying a query hit the same wall (`?path=`, `&mode=inline`, `?vue&type=`, `?ns_worker=1`), so every framework on the Vite dev flow was exposed; Vue merely hit it first. The seam now strips the query only once every HTTP outcome has returned, which is the iOS runtime's ordering: import-map lookup and HTTP-referrer resolution see the full specifier, and only filesystem probing sees the bare path. import() hands its specifier to the seam verbatim and routes on the resolved URL when the seam makes a relative or root-absolute spec HTTP, so those imports stay on the async graph walk instead of the blocking fallback. --- .../assets/app/esm/relative/query-entry.mjs | 8 ++ .../assets/app/tests/testEsmHttpLoader.js | 86 +++++++++++++++++++ .../java/com/tns/tests/ModuleTestServer.java | 15 ++++ .../src/main/cpp/ModuleInternalCallbacks.cpp | 55 ++++++------ 4 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 test-app/app/src/main/assets/app/esm/relative/query-entry.mjs 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/) ──