Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

url: don't update URL immediately on update to URLSearchParams #51520

Merged
merged 18 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2b2d11b
url: remove #context from URLSearchParams
MattIPv4 Jan 19, 2024
129a1a9
url: rely on URLSearchParams if in use for search
MattIPv4 Jan 19, 2024
c2e8526
url: update URLSearchParams directly from URL search setter
MattIPv4 Jan 19, 2024
039380e
url: create href using searchParams without bindingUrl call
MattIPv4 Jan 19, 2024
e015de2
url: ensure ? is present when relying on URLSearchParams
MattIPv4 Jan 19, 2024
2487726
url: don't use public getter for toString/toJSON
MattIPv4 Jan 19, 2024
b0270bc
url: update searchParams tests to expect encoded results
MattIPv4 Jan 19, 2024
d9c5381
url: only use URLSearchParams as store when modified
MattIPv4 Jan 19, 2024
a05675c
url: store search in URL on first access/update after URLSearchParams…
MattIPv4 Jan 19, 2024
310d275
url: add benchmark for param append for URL and URLSearchParams
MattIPv4 Jan 19, 2024
b118a90
url: add benchmark to check impact of searchParams checking
MattIPv4 Jan 19, 2024
916374e
url: reduce diff for changes
MattIPv4 Jan 19, 2024
319eaa0
url: more comments to explain lazy searchParams updating
MattIPv4 Jan 19, 2024
0c44fb6
url: cleanup from review
MattIPv4 Jan 19, 2024
065c45c
url: update variable naming, add more comments, include hash in bench…
MattIPv4 Jan 19, 2024
0d0fef8
url: add tests to ensure lazy URLSearchParams is exercised
MattIPv4 Jan 19, 2024
e9c4da2
url: fix updating url hash etc. after searchParams update, add test
MattIPv4 Jan 21, 2024
04f53a7
url: update tests for invalid this
MattIPv4 Jan 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions benchmark/url/url-searchparams-append.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';
const common = require('../common.js');

const bench = common.createBenchmark(main, {
type: ['URL', 'URLSearchParams'],
n: [1e3, 1e6],
});

function main({ type, n }) {
const params = type === 'URL' ?
new URL('https://nodejs.org').searchParams :
new URLSearchParams();

bench.start();
for (let i = 0; i < n; i++) {
params.append('test', i);
}
bench.end(n);
}
29 changes: 29 additions & 0 deletions benchmark/url/url-searchparams-update.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use strict';
const common = require('../common.js');
const assert = require('assert');

const bench = common.createBenchmark(main, {
searchParams: ['true', 'false'],
property: ['pathname', 'search', 'hash'],
n: [1e6],
});

function getMethod(url, property) {
if (property === 'pathname') return (x) => url.pathname = `/${x}`;
if (property === 'search') return (x) => url.search = `?${x}`;
if (property === 'hash') return (x) => url.hash = `#${x}`;
throw new Error(`Unsupported property "${property}"`);
}

function main({ searchParams, property, n }) {
const url = new URL('https://nodejs.org');
if (searchParams === 'true') assert(url.searchParams);

const method = getMethod(url, property);

bench.start();
for (let i = 0; i < n; i++) {
method(i);
}
bench.end(n);
}
94 changes: 77 additions & 17 deletions lib/internal/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ class URLContext {
}
}

let setURLSearchParamsModified;
let setURLSearchParamsContext;
let getURLSearchParamsList;
let setURLSearchParams;
Expand Down Expand Up @@ -475,8 +476,9 @@ class URLSearchParams {
name = StringPrototypeToWellFormed(`${name}`);
value = StringPrototypeToWellFormed(`${value}`);
ArrayPrototypePush(this.#searchParams, name, value);

if (this.#context) {
this.#context.search = this.toString();
setURLSearchParamsModified(this.#context);
}
}

Expand Down Expand Up @@ -509,8 +511,9 @@ class URLSearchParams {
}
}
}

if (this.#context) {
this.#context.search = this.toString();
setURLSearchParamsModified(this.#context);
}
}

Expand Down Expand Up @@ -615,7 +618,7 @@ class URLSearchParams {
}

if (this.#context) {
this.#context.search = this.toString();
setURLSearchParamsModified(this.#context);
}
}

Expand Down Expand Up @@ -664,7 +667,7 @@ class URLSearchParams {
}

if (this.#context) {
this.#context.search = this.toString();
setURLSearchParamsModified(this.#context);
}
}

Expand Down Expand Up @@ -769,6 +772,20 @@ function isURL(self) {
class URL {
#context = new URLContext();
#searchParams;
#searchParamsModified;

static {
setURLSearchParamsModified = (obj) => {
// When URLSearchParams changes, we lazily update URL on the next read/write for performance.
obj.#searchParamsModified = true;

// If URL has an existing search, remove it without cascading back to URLSearchParams.
// Do this to avoid any internal confusion about whether URLSearchParams or URL is up-to-date.
if (obj.#context.hasSearch) {
obj.#updateContext(bindingUrl.update(obj.#context.href, updateActions.kSearch, ''));
}
};
}

constructor(input, base = undefined) {
markTransferMode(this, false, false);
Expand Down Expand Up @@ -814,7 +831,37 @@ class URL {
return `${constructor.name} ${inspect(obj, opts)}`;
}

#updateContext(href) {
#getSearchFromContext() {
if (!this.#context.hasSearch) return '';
let endsAt = this.#context.href.length;
if (this.#context.hasHash) endsAt = this.#context.hash_start;
if (endsAt - this.#context.search_start <= 1) return '';
return StringPrototypeSlice(this.#context.href, this.#context.search_start, endsAt);
}

#getSearchFromParams() {
if (!this.#searchParams?.size) return '';
return `?${this.#searchParams}`;
}

#ensureSearchParamsUpdated() {
// URL is updated lazily to greatly improve performance when URLSearchParams is updated repeatedly.
// If URLSearchParams has been modified, reflect that back into URL, without cascading back.
if (this.#searchParamsModified) {
this.#searchParamsModified = false;
this.#updateContext(bindingUrl.update(this.#context.href, updateActions.kSearch, this.#getSearchFromParams()));
}
}

/**
* Update the internal context state for URL.
* @param {string} href New href string from `bindingUrl.update`.
* @param {boolean} [shouldUpdateSearchParams] If the update has potential to update search params (href/search).
*/
#updateContext(href, shouldUpdateSearchParams = false) {
const previousSearch = shouldUpdateSearchParams && this.#searchParams &&
(this.#searchParamsModified ? this.#getSearchFromParams() : this.#getSearchFromContext());

this.#context.href = href;

const {
Expand All @@ -840,27 +887,39 @@ class URL {
this.#context.scheme_type = scheme_type;

if (this.#searchParams) {
if (this.#context.hasSearch) {
setURLSearchParams(this.#searchParams, this.search);
} else {
setURLSearchParams(this.#searchParams, undefined);
// If the search string has updated, URL becomes the source of truth, and we update URLSearchParams.
// Only do this when we're expecting it to have changed, otherwise a change to hash etc.
// would incorrectly compare the URLSearchParams state to the empty URL search state.
if (shouldUpdateSearchParams) {
const currentSearch = this.#getSearchFromContext();
if (previousSearch !== currentSearch) {
setURLSearchParams(this.#searchParams, currentSearch);
this.#searchParamsModified = false;
}
}

// If we have a URLSearchParams, ensure that URL is up-to-date with any modification to it.
this.#ensureSearchParamsUpdated();
}
}

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

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

set href(value) {
value = `${value}`;
const href = bindingUrl.update(this.#context.href, updateActions.kHref, value);
if (!href) { throw new ERR_INVALID_URL(value); }
this.#updateContext(href);
this.#updateContext(href, true);
}

// readonly
Expand Down Expand Up @@ -1002,26 +1061,25 @@ class URL {
}

get search() {
if (!this.#context.hasSearch) { return ''; }
let endsAt = this.#context.href.length;
if (this.#context.hasHash) { endsAt = this.#context.hash_start; }
if (endsAt - this.#context.search_start <= 1) { return ''; }
return StringPrototypeSlice(this.#context.href, this.#context.search_start, endsAt);
// Updates to URLSearchParams are lazily propagated to URL, so we need to check we're in sync.
this.#ensureSearchParamsUpdated();
return this.#getSearchFromContext();
}

set search(value) {
const href = bindingUrl.update(this.#context.href, updateActions.kSearch, StringPrototypeToWellFormed(`${value}`));
if (href) {
this.#updateContext(href);
this.#updateContext(href, true);
}
}

// readonly
get searchParams() {
// Create URLSearchParams on demand to greatly improve the URL performance.
if (this.#searchParams == null) {
this.#searchParams = new URLSearchParams(this.search);
this.#searchParams = new URLSearchParams(this.#getSearchFromContext());
setURLSearchParamsContext(this.#searchParams, this);
this.#searchParamsModified = false;
}
return this.#searchParams;
}
Expand All @@ -1041,6 +1099,8 @@ class URL {
}

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

Expand Down
36 changes: 36 additions & 0 deletions test/parallel/test-whatwg-url-custom-searchparams.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,42 @@ assert.strictEqual(sp.toString(), serialized);

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

sp.delete('a');
values.forEach((i) => sp.append('a', i));
assert.strictEqual(m.href, `http://example.org/?${serialized}`);

sp.delete('a');
values.forEach((i) => sp.append('a', i));
assert.strictEqual(m.toString(), `http://example.org/?${serialized}`);

sp.delete('a');
values.forEach((i) => sp.append('a', i));
assert.strictEqual(m.toJSON(), `http://example.org/?${serialized}`);

sp.delete('a');
values.forEach((i) => sp.append('a', i));
m.href = 'http://example.org';
assert.strictEqual(m.href, 'http://example.org/');
assert.strictEqual(sp.size, 0);

sp.delete('a');
values.forEach((i) => sp.append('a', i));
m.search = '';
assert.strictEqual(m.href, 'http://example.org/');
assert.strictEqual(sp.size, 0);

sp.delete('a');
values.forEach((i) => sp.append('a', i));
m.pathname = '/test';
assert.strictEqual(m.href, `http://example.org/test?${serialized}`);
m.pathname = '';

sp.delete('a');
values.forEach((i) => sp.append('a', i));
m.hash = '#test';
assert.strictEqual(m.href, `http://example.org/?${serialized}#test`);
m.hash = '';

assert.strictEqual(sp[Symbol.iterator], sp.entries);

let key, val;
Expand Down
17 changes: 15 additions & 2 deletions test/parallel/test-whatwg-url-invalidthis.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,33 @@ const assert = require('assert');
].forEach((i) => {
assert.throws(() => Reflect.apply(URL.prototype[i], [], {}), {
name: 'TypeError',
message: /Cannot read private member/,
message: /Receiver must be an instance of class/,
});
});

[
'href',
'search',
].forEach((i) => {
assert.throws(() => Reflect.get(URL.prototype, i, {}), {
name: 'TypeError',
message: /Receiver must be an instance of class/,
});

assert.throws(() => Reflect.set(URL.prototype, i, null, {}), {
name: 'TypeError',
message: /Cannot read private member/,
});
});

[
'protocol',
'username',
'password',
'host',
'hostname',
'port',
'pathname',
'search',
'hash',
].forEach((i) => {
assert.throws(() => Reflect.get(URL.prototype, i, {}), {
Expand Down