From e6ecf57aa0ad55157984a0953ff62960f995a7c6 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 31 Jul 2026 03:02:36 -0300 Subject: [PATCH] fix(url): resync cached searchParams when search/href changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The URL.prototype.searchParams getter cached a URLSearchParams built from the query string at first access. A later direct assignment to url.search or url.href left the cache untouched, so subsequent reads returned parameters from the old query, and mutating a held reference wrote that stale set back onto the URL. The cache was also stored as a plain enumerable own property, surfacing in Object.keys(url) and JSON.stringify(url). url.searchParams is a same-object accessor, so the object handed out for a URL must stay identical for the URL's lifetime. Rather than rebuild into a new instance when the query changes, the existing instance is resynced in place — cleared and repopulated through the raw methods captured at construction — and the query string it was built from is recorded alongside it. Mutators resync before applying, so a mutation through a reference held across a reassignment no longer clobbers the new query. Both bookkeeping properties are non-enumerable. The ErrorEvents specs asserted that nothing at all reached the process-wide __onUncaughtError hook during their quiet window; they now assert that the error under test did not, which is what they mean and is immune to async failures draining in from earlier suites. --- NativeScript/runtime/js/blob-url.js | 84 +++++++++-- TestRunner/app/tests/ErrorEventsTests.js | 9 +- TestRunner/app/tests/UrlSearchParamsTests.js | 149 +++++++++++++++++++ TestRunner/app/tests/index.js | 1 + 4 files changed, 226 insertions(+), 17 deletions(-) create mode 100644 TestRunner/app/tests/UrlSearchParamsTests.js diff --git a/NativeScript/runtime/js/blob-url.js b/NativeScript/runtime/js/blob-url.js index f6ce429b..94c3fcf4 100644 --- a/NativeScript/runtime/js/blob-url.js +++ b/NativeScript/runtime/js/blob-url.js @@ -1,4 +1,6 @@ const { + ArrayPrototypePush, + FunctionPrototypeCall, Map, MapPrototypeDelete, MapPrototypeGet, @@ -39,35 +41,89 @@ InternalAccessor.getData = function (url) { return MapPrototypeGet(BLOB_STORE, url); }; URL.InternalAccessor = InternalAccessor; + +// Pushes the params object's serialization onto its URL and records it as the +// query string the params object is in sync with. +function writeBack(params) { + const url = params._url; + url.search = params.toString(); + url._searchParamsSource = url.search; +} + +// Brings `params` back in line with its URL's current query string by mutating +// it in place: url.searchParams is a same-object accessor, so the one params +// object handed out for a URL has to survive every reparse of that URL. Only +// the raw methods captured at construction are used here — the public ones are +// wrapped to write back into the URL, which would clobber the very string being +// applied. +function resync(params) { + const url = params._url; + const search = url.search; + if (url._searchParamsSource === search) { + return; + } + const names = []; + FunctionPrototypeCall(params._forEach, params, function (value, name) { + ArrayPrototypePush(names, name); + }); + for (let i = 0; i < names.length; i++) { + FunctionPrototypeCall(params._delete, params, names[i]); + } + const parsed = new URLSearchParamsCtor(search); + FunctionPrototypeCall(params._forEach, parsed, function (value, name) { + FunctionPrototypeCall(params._append, params, name, value); + }); + url._searchParamsSource = search; +} + ObjectDefineProperty(URL.prototype, 'searchParams', { get() { if (this._searchParams == null) { - this._searchParams = new URLSearchParamsCtor(this.search); - ObjectDefineProperty(this._searchParams, '_url', { + const params = new URLSearchParamsCtor(this.search); + ObjectDefineProperty(this, '_searchParams', { + enumerable: false, + writable: true, + configurable: true, + value: params, + }); + ObjectDefineProperty(this, '_searchParamsSource', { + enumerable: false, + writable: true, + configurable: true, + value: this.search, + }); + ObjectDefineProperty(params, '_url', { enumerable: false, writable: false, value: this, }); - this._searchParams._append = this._searchParams.append; - this._searchParams.append = function (name, value) { + params._forEach = params.forEach; + params._append = params.append; + params.append = function (name, value) { + resync(this); this._append(name, value); - this._url.search = this.toString(); + writeBack(this); }; - this._searchParams._delete = this._searchParams.delete; - this._searchParams.delete = function (name) { + params._delete = params.delete; + params.delete = function (name) { + resync(this); this._delete(name); - this._url.search = this.toString(); + writeBack(this); }; - this._searchParams._set = this._searchParams.set; - this._searchParams.set = function (name, value) { + params._set = params.set; + params.set = function (name, value) { + resync(this); this._set(name, value); - this._url.search = this.toString(); + writeBack(this); }; - this._searchParams._sort = this._searchParams.sort; - this._searchParams.sort = function () { + params._sort = params.sort; + params.sort = function () { + resync(this); this._sort(); - this._url.search = this.toString(); + writeBack(this); }; + } else { + resync(this._searchParams); } return this._searchParams; }, diff --git a/TestRunner/app/tests/ErrorEventsTests.js b/TestRunner/app/tests/ErrorEventsTests.js index 4a3dc6ab..dc312297 100644 --- a/TestRunner/app/tests/ErrorEventsTests.js +++ b/TestRunner/app/tests/ErrorEventsTests.js @@ -67,8 +67,11 @@ describe("WHATWG error events", function () { expect(received.error).toBe(err); expect(received.message).toBe("x"); // preventDefault() in the listener must suppress the __onUncaughtError shim. + // The hook is process-wide, so assert on this error rather than on the + // spy being empty: async failures from earlier suites can still drain + // into it while this test waits. afterQuietTurns(function () { - expect(uncaught.length).toBe(0); + expect(uncaught.indexOf(err)).toBe(-1); done(); }); }); @@ -154,7 +157,7 @@ describe("WHATWG error events", function () { expect(typeof received.reason.stackTrace).toBe("string"); expect(received.reason.stackTrace.length).toBeGreaterThan(0); afterQuietTurns(function () { - expect(uncaught.length).toBe(0); + expect(uncaught.indexOf(reason)).toBe(-1); done(); }); }); @@ -326,7 +329,7 @@ describe("WHATWG error events", function () { expect(received).not.toBeNull(); expect(received.error).toBe(err); afterQuietTurns(function () { - expect(uncaught.length).toBe(0); + expect(uncaught.indexOf(err)).toBe(-1); done(); }); }); diff --git a/TestRunner/app/tests/UrlSearchParamsTests.js b/TestRunner/app/tests/UrlSearchParamsTests.js new file mode 100644 index 00000000..2e8d6e4f --- /dev/null +++ b/TestRunner/app/tests/UrlSearchParamsTests.js @@ -0,0 +1,149 @@ +describe("URL.searchParams caching", function () { + it("returns the same object across reads", function () { + const url = new URL("https://example.com/?a=1"); + expect(url.searchParams).toBe(url.searchParams); + }); + + it("parses the query string on the first read", function () { + const url = new URL("https://example.com/?a=1&b=2&b=3"); + const sp = url.searchParams; + expect(sp.get("a")).toBe("1"); + expect(sp.getAll("b")).toEqual(["2", "3"]); + expect(sp.size).toBe(3); + }); + + it("keeps the same object after assigning to search", function () { + const url = new URL("https://example.com/?a=1"); + const sp = url.searchParams; + url.search = "?x=1"; + expect(url.searchParams).toBe(sp); + }); + + it("refreshes after assigning to search", function () { + const url = new URL("https://example.com/?a=1"); + const sp = url.searchParams; + expect(sp.get("a")).toBe("1"); + + url.search = "?b=2&c=3"; + + expect(url.searchParams.get("a")).toBe(null); + expect(url.searchParams.get("b")).toBe("2"); + expect(url.searchParams.get("c")).toBe("3"); + expect(url.searchParams.size).toBe(2); + expect(sp.get("b")).toBe("2"); + }); + + it("refreshes after assigning to href", function () { + const url = new URL("https://example.com/?a=1"); + const sp = url.searchParams; + expect(sp.get("a")).toBe("1"); + + url.href = "https://example.com/other?q=hello&lang=en"; + + expect(url.searchParams).toBe(sp); + expect(url.searchParams.get("a")).toBe(null); + expect(url.searchParams.get("q")).toBe("hello"); + expect(url.searchParams.get("lang")).toBe("en"); + }); + + it("refreshes to empty when the query is cleared", function () { + const url = new URL("https://example.com/?a=1&b=2"); + const sp = url.searchParams; + expect(sp.size).toBe(2); + + url.search = ""; + + expect(url.searchParams).toBe(sp); + expect(url.searchParams.size).toBe(0); + expect(url.searchParams.toString()).toBe(""); + }); + + it("preserves duplicate keys when refreshing", function () { + const url = new URL("https://example.com/?a=1"); + const sp = url.searchParams; + + url.search = "?a=1&a=2&a=3"; + + expect(url.searchParams.getAll("a")).toEqual(["1", "2", "3"]); + expect(sp.getAll("a")).toEqual(["1", "2", "3"]); + }); + + it("writes mutations back to the url", function () { + const url = new URL("https://example.com/?a=1"); + const sp = url.searchParams; + + sp.set("a", "9"); + expect(url.search).toBe("?a=9"); + + sp.append("b", "2"); + expect(url.search).toBe("?a=9&b=2"); + expect(url.href).toBe("https://example.com/?a=9&b=2"); + + sp.delete("a"); + expect(url.search).toBe("?b=2"); + + sp.append("a", "0"); + sp.sort(); + expect(url.search).toBe("?a=0&b=2"); + }); + + it("does not drop state on the read following a mutation", function () { + const url = new URL("https://example.com/?a=1"); + const sp = url.searchParams; + + sp.append("b", "2"); + sp.append("b", "3"); + + const spAfter = url.searchParams; + expect(spAfter).toBe(sp); + expect(spAfter.getAll("b")).toEqual(["2", "3"]); + expect(spAfter.get("a")).toBe("1"); + expect(spAfter.size).toBe(3); + }); + + it("interleaves mutations and direct assignments", function () { + const url = new URL("https://example.com/?a=1"); + const sp = url.searchParams; + + sp.set("a", "2"); + url.search = "?b=1"; + + expect(url.searchParams.get("a")).toBe(null); + expect(url.searchParams.get("b")).toBe("1"); + + sp.set("c", "3"); + expect(url.search).toBe("?b=1&c=3"); + expect(url.searchParams.get("c")).toBe("3"); + }); + + it("does not clobber a reassigned query when mutating a held reference", function () { + const url = new URL("https://example.com/?a=1"); + const sp = url.searchParams; + + url.search = "?b=1"; + sp.set("c", "3"); + + expect(url.search).toBe("?b=1&c=3"); + expect(sp.get("a")).toBe(null); + }); + + it("does not expose its cache as an enumerable property", function () { + const url = new URL("https://example.com/?a=1"); + UNUSED(url.searchParams); + url.search = "?b=2"; + UNUSED(url.searchParams); + + const keys = Object.keys(url); + expect(keys.indexOf("_searchParams")).toBe(-1); + expect(keys.indexOf("_searchParamsSource")).toBe(-1); + + const serialized = JSON.stringify(url); + expect(serialized.indexOf("_searchParams")).toBe(-1); + expect(serialized.indexOf("_searchParamsSource")).toBe(-1); + + for (const key in url) { + expect(key).not.toBe("_searchParams"); + expect(key).not.toBe("_searchParamsSource"); + } + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 5ad49d89..e40a07c4 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -140,6 +140,7 @@ require("./Timers"); require("./URL"); require("./URLSearchParams"); +require("./UrlSearchParamsTests"); require("./URLPattern"); // HTTP ESM Loader tests