Skip to content

Commit 4bc8e98

Browse files
MattIPv4richardlau
authored andcommitted
url: don't update URL immediately on update to URLSearchParams
PR-URL: #51520 Fixes: #51518 Backport-PR-URL: #51559 Reviewed-By: Antoine du Hamel <[email protected]> Reviewed-By: Yagiz Nizipli <[email protected]> Reviewed-By: Matteo Collina <[email protected]>
1 parent 0211a3d commit 4bc8e98

5 files changed

+176
-19
lines changed
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use strict';
2+
const common = require('../common.js');
3+
4+
const bench = common.createBenchmark(main, {
5+
type: ['URL', 'URLSearchParams'],
6+
n: [1e3, 1e6],
7+
});
8+
9+
function main({ type, n }) {
10+
const params = type === 'URL' ?
11+
new URL('https://nodejs.org').searchParams :
12+
new URLSearchParams();
13+
14+
bench.start();
15+
for (let i = 0; i < n; i++) {
16+
params.append('test', i);
17+
}
18+
bench.end(n);
19+
}
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use strict';
2+
const common = require('../common.js');
3+
const assert = require('assert');
4+
5+
const bench = common.createBenchmark(main, {
6+
searchParams: ['true', 'false'],
7+
property: ['pathname', 'search', 'hash'],
8+
n: [1e6],
9+
});
10+
11+
function getMethod(url, property) {
12+
if (property === 'pathname') return (x) => url.pathname = `/${x}`;
13+
if (property === 'search') return (x) => url.search = `?${x}`;
14+
if (property === 'hash') return (x) => url.hash = `#${x}`;
15+
throw new Error(`Unsupported property "${property}"`);
16+
}
17+
18+
function main({ searchParams, property, n }) {
19+
const url = new URL('https://nodejs.org');
20+
if (searchParams === 'true') assert(url.searchParams);
21+
22+
const method = getMethod(url, property);
23+
24+
bench.start();
25+
for (let i = 0; i < n; i++) {
26+
method(i);
27+
}
28+
bench.end(n);
29+
}

lib/internal/url.js

+77-17
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ class URLContext {
202202
}
203203
}
204204

205+
let setURLSearchParamsModified;
205206
let setURLSearchParamsContext;
206207
let getURLSearchParamsList;
207208
let setURLSearchParams;
@@ -469,8 +470,9 @@ class URLSearchParams {
469470
name = StringPrototypeToWellFormed(`${name}`);
470471
value = StringPrototypeToWellFormed(`${value}`);
471472
ArrayPrototypePush(this.#searchParams, name, value);
473+
472474
if (this.#context) {
473-
this.#context.search = this.toString();
475+
setURLSearchParamsModified(this.#context);
474476
}
475477
}
476478

@@ -503,8 +505,9 @@ class URLSearchParams {
503505
}
504506
}
505507
}
508+
506509
if (this.#context) {
507-
this.#context.search = this.toString();
510+
setURLSearchParamsModified(this.#context);
508511
}
509512
}
510513

@@ -609,7 +612,7 @@ class URLSearchParams {
609612
}
610613

611614
if (this.#context) {
612-
this.#context.search = this.toString();
615+
setURLSearchParamsModified(this.#context);
613616
}
614617
}
615618

@@ -658,7 +661,7 @@ class URLSearchParams {
658661
}
659662

660663
if (this.#context) {
661-
this.#context.search = this.toString();
664+
setURLSearchParamsModified(this.#context);
662665
}
663666
}
664667

@@ -763,6 +766,20 @@ function isURL(self) {
763766
class URL {
764767
#context = new URLContext();
765768
#searchParams;
769+
#searchParamsModified;
770+
771+
static {
772+
setURLSearchParamsModified = (obj) => {
773+
// When URLSearchParams changes, we lazily update URL on the next read/write for performance.
774+
obj.#searchParamsModified = true;
775+
776+
// If URL has an existing search, remove it without cascading back to URLSearchParams.
777+
// Do this to avoid any internal confusion about whether URLSearchParams or URL is up-to-date.
778+
if (obj.#context.hasSearch) {
779+
obj.#updateContext(bindingUrl.update(obj.#context.href, updateActions.kSearch, ''));
780+
}
781+
};
782+
}
766783

767784
constructor(input, base = undefined) {
768785
if (arguments.length === 0) {
@@ -806,7 +823,37 @@ class URL {
806823
return `${constructor.name} ${inspect(obj, opts)}`;
807824
}
808825

809-
#updateContext(href) {
826+
#getSearchFromContext() {
827+
if (!this.#context.hasSearch) return '';
828+
let endsAt = this.#context.href.length;
829+
if (this.#context.hasHash) endsAt = this.#context.hash_start;
830+
if (endsAt - this.#context.search_start <= 1) return '';
831+
return StringPrototypeSlice(this.#context.href, this.#context.search_start, endsAt);
832+
}
833+
834+
#getSearchFromParams() {
835+
if (!this.#searchParams?.size) return '';
836+
return `?${this.#searchParams}`;
837+
}
838+
839+
#ensureSearchParamsUpdated() {
840+
// URL is updated lazily to greatly improve performance when URLSearchParams is updated repeatedly.
841+
// If URLSearchParams has been modified, reflect that back into URL, without cascading back.
842+
if (this.#searchParamsModified) {
843+
this.#searchParamsModified = false;
844+
this.#updateContext(bindingUrl.update(this.#context.href, updateActions.kSearch, this.#getSearchFromParams()));
845+
}
846+
}
847+
848+
/**
849+
* Update the internal context state for URL.
850+
* @param {string} href New href string from `bindingUrl.update`.
851+
* @param {boolean} [shouldUpdateSearchParams] If the update has potential to update search params (href/search).
852+
*/
853+
#updateContext(href, shouldUpdateSearchParams = false) {
854+
const previousSearch = shouldUpdateSearchParams && this.#searchParams &&
855+
(this.#searchParamsModified ? this.#getSearchFromParams() : this.#getSearchFromContext());
856+
810857
this.#context.href = href;
811858

812859
const {
@@ -832,27 +879,39 @@ class URL {
832879
this.#context.scheme_type = scheme_type;
833880

834881
if (this.#searchParams) {
835-
if (this.#context.hasSearch) {
836-
setURLSearchParams(this.#searchParams, this.search);
837-
} else {
838-
setURLSearchParams(this.#searchParams, undefined);
882+
// If the search string has updated, URL becomes the source of truth, and we update URLSearchParams.
883+
// Only do this when we're expecting it to have changed, otherwise a change to hash etc.
884+
// would incorrectly compare the URLSearchParams state to the empty URL search state.
885+
if (shouldUpdateSearchParams) {
886+
const currentSearch = this.#getSearchFromContext();
887+
if (previousSearch !== currentSearch) {
888+
setURLSearchParams(this.#searchParams, currentSearch);
889+
this.#searchParamsModified = false;
890+
}
839891
}
892+
893+
// If we have a URLSearchParams, ensure that URL is up-to-date with any modification to it.
894+
this.#ensureSearchParamsUpdated();
840895
}
841896
}
842897

843898
toString() {
899+
// Updates to URLSearchParams are lazily propagated to URL, so we need to check we're in sync.
900+
this.#ensureSearchParamsUpdated();
844901
return this.#context.href;
845902
}
846903

847904
get href() {
905+
// Updates to URLSearchParams are lazily propagated to URL, so we need to check we're in sync.
906+
this.#ensureSearchParamsUpdated();
848907
return this.#context.href;
849908
}
850909

851910
set href(value) {
852911
value = `${value}`;
853912
const href = bindingUrl.update(this.#context.href, updateActions.kHref, value);
854913
if (!href) { throw new ERR_INVALID_URL(value); }
855-
this.#updateContext(href);
914+
this.#updateContext(href, true);
856915
}
857916

858917
// readonly
@@ -994,26 +1053,25 @@ class URL {
9941053
}
9951054

9961055
get search() {
997-
if (!this.#context.hasSearch) { return ''; }
998-
let endsAt = this.#context.href.length;
999-
if (this.#context.hasHash) { endsAt = this.#context.hash_start; }
1000-
if (endsAt - this.#context.search_start <= 1) { return ''; }
1001-
return StringPrototypeSlice(this.#context.href, this.#context.search_start, endsAt);
1056+
// Updates to URLSearchParams are lazily propagated to URL, so we need to check we're in sync.
1057+
this.#ensureSearchParamsUpdated();
1058+
return this.#getSearchFromContext();
10021059
}
10031060

10041061
set search(value) {
10051062
const href = bindingUrl.update(this.#context.href, updateActions.kSearch, StringPrototypeToWellFormed(`${value}`));
10061063
if (href) {
1007-
this.#updateContext(href);
1064+
this.#updateContext(href, true);
10081065
}
10091066
}
10101067

10111068
// readonly
10121069
get searchParams() {
10131070
// Create URLSearchParams on demand to greatly improve the URL performance.
10141071
if (this.#searchParams == null) {
1015-
this.#searchParams = new URLSearchParams(this.search);
1072+
this.#searchParams = new URLSearchParams(this.#getSearchFromContext());
10161073
setURLSearchParamsContext(this.#searchParams, this);
1074+
this.#searchParamsModified = false;
10171075
}
10181076
return this.#searchParams;
10191077
}
@@ -1033,6 +1091,8 @@ class URL {
10331091
}
10341092

10351093
toJSON() {
1094+
// Updates to URLSearchParams are lazily propagated to URL, so we need to check we're in sync.
1095+
this.#ensureSearchParamsUpdated();
10361096
return this.#context.href;
10371097
}
10381098

test/parallel/test-whatwg-url-custom-searchparams.js

+36
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,42 @@ assert.strictEqual(sp.toString(), serialized);
4343

4444
assert.strictEqual(m.search, `?${serialized}`);
4545

46+
sp.delete('a');
47+
values.forEach((i) => sp.append('a', i));
48+
assert.strictEqual(m.href, `http://example.org/?${serialized}`);
49+
50+
sp.delete('a');
51+
values.forEach((i) => sp.append('a', i));
52+
assert.strictEqual(m.toString(), `http://example.org/?${serialized}`);
53+
54+
sp.delete('a');
55+
values.forEach((i) => sp.append('a', i));
56+
assert.strictEqual(m.toJSON(), `http://example.org/?${serialized}`);
57+
58+
sp.delete('a');
59+
values.forEach((i) => sp.append('a', i));
60+
m.href = 'http://example.org';
61+
assert.strictEqual(m.href, 'http://example.org/');
62+
assert.strictEqual(sp.size, 0);
63+
64+
sp.delete('a');
65+
values.forEach((i) => sp.append('a', i));
66+
m.search = '';
67+
assert.strictEqual(m.href, 'http://example.org/');
68+
assert.strictEqual(sp.size, 0);
69+
70+
sp.delete('a');
71+
values.forEach((i) => sp.append('a', i));
72+
m.pathname = '/test';
73+
assert.strictEqual(m.href, `http://example.org/test?${serialized}`);
74+
m.pathname = '';
75+
76+
sp.delete('a');
77+
values.forEach((i) => sp.append('a', i));
78+
m.hash = '#test';
79+
assert.strictEqual(m.href, `http://example.org/?${serialized}#test`);
80+
m.hash = '';
81+
4682
assert.strictEqual(sp[Symbol.iterator], sp.entries);
4783

4884
let key, val;

test/parallel/test-whatwg-url-invalidthis.js

+15-2
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,33 @@ const assert = require('assert');
1111
].forEach((i) => {
1212
assert.throws(() => Reflect.apply(URL.prototype[i], [], {}), {
1313
name: 'TypeError',
14-
message: /Cannot read private member/,
14+
message: /Receiver must be an instance of class/,
1515
});
1616
});
1717

1818
[
1919
'href',
20+
'search',
21+
].forEach((i) => {
22+
assert.throws(() => Reflect.get(URL.prototype, i, {}), {
23+
name: 'TypeError',
24+
message: /Receiver must be an instance of class/,
25+
});
26+
27+
assert.throws(() => Reflect.set(URL.prototype, i, null, {}), {
28+
name: 'TypeError',
29+
message: /Cannot read private member/,
30+
});
31+
});
32+
33+
[
2034
'protocol',
2135
'username',
2236
'password',
2337
'host',
2438
'hostname',
2539
'port',
2640
'pathname',
27-
'search',
2841
'hash',
2942
].forEach((i) => {
3043
assert.throws(() => Reflect.get(URL.prototype, i, {}), {

0 commit comments

Comments
 (0)