Skip to content

Commit 60d03e9

Browse files
authored
fix: keep the query on relative and root-absolute imports from served modules (#2038)
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.
1 parent e552457 commit 60d03e9

4 files changed

Lines changed: 140 additions & 24 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import defaultValue, { relativeValue } from "./dependency.mjs?v=static";
2+
3+
export const viaDefault = defaultValue;
4+
export const viaNamed = relativeValue;
5+
6+
export function loadWithQuery() {
7+
return import("./dependency.mjs?v=dynamic");
8+
}

test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ describe("HTTP ESM Loader", function () {
7373
});
7474
});
7575

76+
// A query or fragment on a specifier that names a file is URL syntax
77+
// the filesystem never sees. The same statement in a served module
78+
// keeps it — see "query-bearing specifiers from a served referrer".
79+
it("drops the query when a local import names a file", function (done) {
80+
import("~/esm/relative/query-entry.mjs?v=entry").then(function (module) {
81+
expect(module.viaDefault).toBe("relative-import-success");
82+
expect(module.viaNamed).toBe("relative-import-success");
83+
return module.loadWithQuery();
84+
}).then(function (dependency) {
85+
expect(dependency.relativeValue).toBe("relative-import-success");
86+
done();
87+
}).catch(function (error) {
88+
reportRejection(error, done);
89+
});
90+
});
91+
7692
it("should surface helpful errors for unresolved bare specifiers", function (done) {
7793
import("bare-spec-example").then(function (mod) {
7894
// A placeholder module default-exports a Proxy whose get trap
@@ -93,6 +109,76 @@ describe("HTTP ESM Loader", function () {
93109
});
94110
});
95111

112+
// A served module's relative and root-absolute imports resolve against its
113+
// URL, and a query on them is part of the resulting module's identity:
114+
// `/esm/query.mjs?v=a` and `/esm/query.mjs` are two modules to the server,
115+
// exactly as `/ns/asm?path=...` and `/ns/asm` are to a dev server. Every
116+
// specifier shape must reach the server with its query intact.
117+
describe("query-bearing specifiers from a served referrer", function () {
118+
useHttpTimeout();
119+
120+
var nsModule = require("ns:module");
121+
var formsUrl = origin + "/esm/query-forms.mjs";
122+
123+
afterEach(function () {
124+
nsModule.configureLoader({ importMap: { imports: {} } });
125+
});
126+
127+
it("keeps the query on static root-absolute and relative imports", function (done) {
128+
withTimeout(import(formsUrl), 10000, "import " + formsUrl)
129+
.then(function (mod) {
130+
expect(mod.path).toBe("/esm/query.mjs");
131+
expect(mod.query).toContain("v=root-abs");
132+
expect(mod.relativeQuery).toContain("v=relative");
133+
// `export *` and `export { default }` name one URL, so
134+
// they share one evaluated instance.
135+
expect(mod.default.query).toContain("v=root-abs");
136+
expect(mod.default.evaluatedAt).toBe(mod.evaluatedAt);
137+
done();
138+
})
139+
.catch(function (error) {
140+
reportRejection(error, done);
141+
});
142+
});
143+
144+
it("keeps the query on dynamic root-absolute and relative imports", function (done) {
145+
var forms;
146+
withTimeout(import(formsUrl), 10000, "import " + formsUrl)
147+
.then(function (mod) {
148+
forms = mod;
149+
return withTimeout(forms.loadRootAbs(), 10000, "dynamic root-absolute import");
150+
})
151+
.then(function (rootAbs) {
152+
expect(rootAbs.path).toBe("/esm/query.mjs");
153+
expect(rootAbs.query).toContain("v=dyn-root");
154+
return withTimeout(forms.loadRelative(), 10000, "dynamic relative import");
155+
})
156+
.then(function (relative) {
157+
expect(relative.path).toBe("/esm/query.mjs");
158+
expect(relative.query).toContain("v=dyn-rel");
159+
done();
160+
})
161+
.catch(function (error) {
162+
reportRejection(error, done);
163+
});
164+
});
165+
166+
it("keeps the query through an import-map prefix entry", function (done) {
167+
nsModule.configureLoader({
168+
importMap: { imports: { "ns-test-esm/": origin + "/esm/" } },
169+
});
170+
withTimeout(import("ns-test-esm/query.mjs?v=prefix"), 10000, "prefix-mapped import")
171+
.then(function (mod) {
172+
expect(mod.path).toBe("/esm/query.mjs");
173+
expect(mod.query).toContain("v=prefix");
174+
done();
175+
})
176+
.catch(function (error) {
177+
reportRejection(error, done);
178+
});
179+
});
180+
});
181+
96182
describe("HTTP Fetch Integration", function () {
97183

98184
it("settles a local dynamic import issued from a background thread", function (done) {

test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,21 @@ private static void route(Socket socket, String path, String query) throws IOExc
154154
return;
155155
}
156156

157+
if ("/esm/query-forms.mjs".equals(path)) {
158+
// Every specifier shape a served module can use to reach a sibling
159+
// whose query is its identity. The server must receive each query
160+
// intact: `/esm/query.mjs?v=x` and `/esm/query.mjs` are different
161+
// modules to it, as `/ns/asm?path=...` and `/ns/asm` are to a dev
162+
// server.
163+
String body = "export * from \"/esm/query.mjs?v=root-abs\";\n"
164+
+ "export { default } from \"/esm/query.mjs?v=root-abs\";\n"
165+
+ "export { query as relativeQuery } from \"./query.mjs?v=relative\";\n"
166+
+ "export function loadRootAbs() { return import(\"/esm/query.mjs?v=dyn-root\"); }\n"
167+
+ "export function loadRelative() { return import(\"./query.mjs?v=dyn-rel\"); }\n";
168+
respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8));
169+
return;
170+
}
171+
157172
if ("/esm/html-fallback.mjs".equals(path)) {
158173
// The SPA-fallback shape: an unknown path answered with the index
159174
// document, 200 OK. The module loader must reject it on MIME rather

test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,16 +1147,6 @@ static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec,
11471147
spec.insert(6, "/");
11481148
}
11491149

1150-
// Query and fragment only mean something to a server, so a non-http
1151-
// specifier drops them before anything looks it up. Applied here, in the one
1152-
// seam both import forms go through, so `./x.js?v=1` names the same module
1153-
// whether it arrives as a static import or an import().
1154-
if (!(StartsWith(spec, "http://") || StartsWith(spec, "https://"))) {
1155-
size_t cut = spec.find_first_of("?#");
1156-
if (cut != std::string::npos) spec = spec.substr(0, cut);
1157-
if (spec.empty()) return result;
1158-
}
1159-
11601150
TNS_DEBUG(Esm, "[resolver][spec] %s", spec.c_str());
11611151

11621152
// The import map is consulted before any other resolution: bare specifiers
@@ -1223,6 +1213,24 @@ static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec,
12231213
}
12241214
}
12251215

1216+
// A query or fragment is URL syntax, never part of a file name, so
1217+
// `import './x.js?v=1'` names x.js on disk. It is dropped only here, after
1218+
// every HTTP outcome has returned: for a served module the query is part of
1219+
// its identity (`/ns/asm?path=A` and `/ns/asm` are two modules to the
1220+
// server), and that holds for a root-absolute or relative specifier just as
1221+
// it does for an absolute URL. Applied in this one seam, so a static import
1222+
// and an import() of `./x.js?v=1` name the same file.
1223+
{
1224+
size_t cut = spec.find_first_of("?#");
1225+
if (cut != std::string::npos) {
1226+
std::string stripped = spec.substr(0, cut);
1227+
TNS_DEBUG(Esm, "[resolver][strip-query] %s -> %s", spec.c_str(),
1228+
stripped.c_str());
1229+
spec = stripped;
1230+
}
1231+
if (spec.empty()) return result;
1232+
}
1233+
12261234
// Build the filesystem candidates for this specifier shape. The specifier may
12271235
// omit its extension or name a directory, so each candidate is probed with
12281236
// Node-style extension and index fallbacks below.
@@ -2533,21 +2541,10 @@ v8::MaybeLocal<v8::Promise> ImportModuleDynamicallyCallback(
25332541
return builtinScope.Escape(builtinResolver->GetPromise());
25342542
}
25352543

2544+
// The specifier reaches the shared seam verbatim. Whether its query is
2545+
// identity (a served module) or noise (a file) is the seam's decision, made
2546+
// the same way for a static import and an import().
25362547
std::string normalizedSpec = rawSpec;
2537-
// remove query/hash ONLY for non-HTTP specs
2538-
bool isHttpLike =
2539-
(!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") ||
2540-
StartsWith(normalizedSpec, "https://")));
2541-
if (!isHttpLike) {
2542-
size_t qpos = normalizedSpec.find_first_of("?#");
2543-
if (qpos != std::string::npos) {
2544-
normalizedSpec = normalizedSpec.substr(0, qpos);
2545-
}
2546-
}
2547-
if (normalizedSpec != rawSpec) {
2548-
TNS_DEBUG(Esm, "[dyn-import][normalize] %s -> %s", rawSpec.c_str(),
2549-
normalizedSpec.c_str());
2550-
}
25512548

25522549
v8::EscapableHandleScope scope(isolate);
25532550

@@ -2579,6 +2576,16 @@ v8::MaybeLocal<v8::Promise> ImportModuleDynamicallyCallback(
25792576
TNS_DEBUG(Esm, "[dyn-import][import-map] rewrite: %s -> %s",
25802577
rawSpec.c_str(), normalizedSpec.c_str());
25812578
}
2579+
// A relative or root-absolute specifier from a served referrer resolves to
2580+
// a URL the specifier itself never spells out. Routing on that URL keeps
2581+
// such an import() on the async fetch path with its absolute-URL siblings.
2582+
if (dynamicResolution.kind == ModuleResolution::Kind::kHttp &&
2583+
!dynamicResolution.url.empty() &&
2584+
dynamicResolution.url != normalizedSpec) {
2585+
TNS_DEBUG(Esm, "[dyn-import][http-rel] %s -> %s", normalizedSpec.c_str(),
2586+
dynamicResolution.url.c_str());
2587+
normalizedSpec = dynamicResolution.url;
2588+
}
25822589

25832590
try {
25842591
// ── Blob URL support (e.g. blob:nativescript/<uuid>) ──

0 commit comments

Comments
 (0)