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