From b97a70b83619d7bf86b84fa46734137b8bb6ed70 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sun, 20 Sep 2026 12:53:25 +0000 Subject: [PATCH 1/2] fix(runtime): install URLSearchParams prototype methods as reified closures URLSearchParams's `#10555` arm in populate_builtin_prototype_methods only installed a Symbol.toStringTag descriptor, on the assumption that its methods are always reached through type-directed static dispatch or the small-int/handle dispatch tables. That assumption breaks for a method read AS A VALUE: `URLSearchParams.prototype.append`, `.prototype["has"]`, or a Proxy `get` trap indirection all returned `undefined` instead of a callable, name-carrying closure. node-fetch@3.3.2's `Headers extends URLSearchParams` returns a Proxy from its constructor whose `get` trap does exactly this (`URLSearchParams.prototype[p].call(target, ...)`), and `headers.has(...)` is reached on every `fetch()` call before the request is sent, so this threw "Function.prototype.call was called on a value that is not a function" on the very first fetch. Install the same no-op-backed reified-closure set the neighboring Headers/ URLPattern/Request/Response arms already use, dispatched by name through try_url_search_params_dynamic_dispatch. The toStringTag install is kept so reflection on URLSearchParams.prototype itself doesn't regress. Added test-files/test_gap_10759_urlsearchparams_prototype_method_value.ts, verified byte-identical against node --experimental-strip-types (v26.5.1). --- .../src/object/global_this/proto_methods.rs | 65 ++++++++- ..._urlsearchparams_prototype_method_value.ts | 136 ++++++++++++++++++ 2 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 test-files/test_gap_10759_urlsearchparams_prototype_method_value.ts diff --git a/crates/perry-runtime/src/object/global_this/proto_methods.rs b/crates/perry-runtime/src/object/global_this/proto_methods.rs index fa3f8c5231..9bc53ab65b 100644 --- a/crates/perry-runtime/src/object/global_this/proto_methods.rs +++ b/crates/perry-runtime/src/object/global_this/proto_methods.rs @@ -803,6 +803,59 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: ); } } + // #10759: `URLSearchParams` previously had ONLY the `#10555` arm + // below (moved here): `install_web_builtin_to_string_tag` and + // nothing else, because its methods dispatch through type-directed + // static dispatch / the small-int/handle dispatch tables and never + // needed reified closures for ordinary `x.method()` calls. That + // design has no answer for a method read AS A VALUE -- + // `URLSearchParams.prototype.append`, `.prototype["has"]`, or + // through a Proxy `get` trap indirection -- which returned + // `undefined` instead of a callable closure. node-fetch's + // `Headers extends URLSearchParams` -- whose constructor returns + // `new Proxy(this, { get(target, p, receiver) { ... return + // (...)=> URLSearchParams.prototype[p].call(target, ...); } })` -- + // then threw "Function.prototype.call was called on a value that is + // not a function" on the very first `headers.has(...)`, reached by + // every `fetch()` call before the request is even sent. Same + // mechanism as the `Stream.prototype`/`Object.hasOwnProperty`/ + // `Function.toString` fixes elsewhere (`install_static.rs`, + // `node_stream_dispatch.rs`): install the no-op-backed reified + // closures so a value read resolves to a real (name-carrying) + // function, which `Function.prototype.call`/`.apply`'s + // `try_dispatch_value_called_proto_method` re-dispatches by name + // through `try_url_search_params_dynamic_dispatch` using the + // caller-supplied receiver. Method set + arities verified against + // `node --experimental-strip-types` (v26.5.1). The + // `install_web_builtin_to_string_tag` call is retained so + // `Object.getOwnPropertyDescriptor(URLSearchParams.prototype, + // Symbol.toStringTag)` keeps reflecting a real descriptor -- see + // that function's doc comment. The other six members of the + // `#10555` group below (`URL`, `AbortController`, `AbortSignal`, + // `EventTarget`, `Event`, `CustomEvent`) have the same + // "toStringTag-only arm" shape and have NOT been audited for this + // same value-read gap; see #10759's PR body for what was checked. + "URLSearchParams" => { + install_noop_proto_methods( + proto_obj, + &[ + ("append", 2), + ("delete", 1), + ("entries", 0), + ("forEach", 1), + ("get", 1), + ("getAll", 1), + ("has", 1), + ("keys", 0), + ("set", 2), + ("sort", 0), + ("toString", 0), + ("values", 0), + ], + ); + install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS); + unsafe { install_web_builtin_to_string_tag(proto_obj, "URLSearchParams") }; + } "Promise" => { install_proto_method( proto_obj, @@ -1088,11 +1141,15 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: // is either type-directed static dispatch or the small-int/handle // dispatch tables), but each still needs its `.prototype`'s own // `Symbol.toStringTag` descriptor for reflection -- see - // `install_web_builtin_to_string_tag`'s doc comment. + // `install_web_builtin_to_string_tag`'s doc comment. `URLSearchParams` + // used to be listed here too; #10759 moved it to its own arm above + // (still calling `install_web_builtin_to_string_tag`) once a VALUE + // read of one of its prototype methods turned out to need real + // reified closures, not just the toStringTag descriptor. The other + // six members of this group (`URL`, `AbortController`, + // `AbortSignal`, `EventTarget`, `Event`, `CustomEvent`) have not been + // audited for the same "read as a value" gap -- see #10759's PR body. "URL" => unsafe { install_web_builtin_to_string_tag(proto_obj, "URL") }, - "URLSearchParams" => unsafe { - install_web_builtin_to_string_tag(proto_obj, "URLSearchParams") - }, "AbortController" => unsafe { install_web_builtin_to_string_tag(proto_obj, "AbortController") }, diff --git a/test-files/test_gap_10759_urlsearchparams_prototype_method_value.ts b/test-files/test_gap_10759_urlsearchparams_prototype_method_value.ts new file mode 100644 index 0000000000..baaaf4f6a5 --- /dev/null +++ b/test-files/test_gap_10759_urlsearchparams_prototype_method_value.ts @@ -0,0 +1,136 @@ +// Gap test for #10759 — `URLSearchParams.prototype` had no entry in +// `populate_builtin_prototype_methods` (crates/perry-runtime/src/object/ +// global_this/proto_methods.rs), unlike every neighboring builtin (`Headers`, +// `URLPattern`, `Request`/`Response`, ...). Its prototype methods were never +// installed as real (name-carrying, callable) property VALUES, so any read +// of `URLSearchParams.prototype.` — literal, computed by a runtime +// string, or through a Proxy `get` trap — returned `undefined` instead of a +// function, and `.call()`/`.apply()` on that then threw: +// TypeError: Function.prototype.call was called on a value that is not a +// function +// +// This is the exact shape node-fetch@3.3.2's `Headers` class hits on EVERY +// `fetch()` call: `class Headers extends URLSearchParams` returns +// `new Proxy(this, { get(target, p, receiver) { ... return (...) => +// URLSearchParams.prototype[p].call(target, ...); } })` from its +// constructor, and `getNodeRequestOptions()` calls `headers.has('Accept')` +// unconditionally before the request is even sent. +// +// Same underlying mechanism as the sibling fixes already in-repo for other +// builtins: `require('stream').prototype` (object/native_module/ +// constants.rs) and `Function.toString`/`Object.hasOwnProperty` as values +// (crates/perry/tests/issue_5135_proxy_compound_and_function_tostring.rs) — +// a built-in prototype's methods must be installed as real closures, or a +// value-read misses regardless of how the read is spelled. +// Byte-identical to `node --experimental-strip-types` (v26.5.1). + +// ---- typeof / name / length: literal, literal-string, and computed-by-variable access ---- +{ + const methods = [ + "append", + "delete", + "entries", + "forEach", + "get", + "getAll", + "has", + "keys", + "set", + "sort", + "toString", + "values", + ] as const; + const proto: any = URLSearchParams.prototype; + const out: string[] = []; + for (const m of methods) { + const literal = typeof proto[m]; + const literalStr = typeof proto[m as string]; + const k: string = m; + const computed = typeof proto[k]; + out.push(`${m}:${literal},${literalStr},${computed},len=${proto[m].length},name=${proto[m].name}`); + } + console.log("typeof-suite:", out.join(" ")); +} + +// ---- direct `.call()` on the literal-read method, mutating a real receiver ---- +{ + const usp = new URLSearchParams(); + URLSearchParams.prototype.append.call(usp, "a", "1"); + console.log("literal-call:", usp.toString()); +} + +// ---- `.call()` through a runtime-variable computed key (the exact shape +// node-fetch's Headers.js uses inside its Proxy trap) ---- +{ + const usp = new URLSearchParams(); + const k = "append"; + (URLSearchParams.prototype as any)[k].call(usp, "a", "1"); + const k2 = "has"; + const hasA = (URLSearchParams.prototype as any)[k2].call(usp, "a"); + const hasB = (URLSearchParams.prototype as any)[k2].call(usp, "b"); + console.log("computed-call:", usp.toString(), hasA, hasB); +} + +// ---- the node-fetch `Headers` shape itself: a subclass whose constructor +// returns a Proxy wrapping `this`, whose `get` trap reads +// `URLSearchParams.prototype[p]` by a closure-captured (not literal) +// variable and calls it with `.call(target, ...)`. ---- +class FetchLikeHeaders extends URLSearchParams { + constructor() { + super(); + const target: any = this; + // eslint-disable-next-line no-constructor-return + return new Proxy(target, { + get(target: any, p: any, receiver: any) { + switch (p) { + case "append": + case "set": + return (name: string, value: string) => { + return (URLSearchParams.prototype as any)[p].call(target, name, value); + }; + case "delete": + case "has": + case "getAll": + return (name: string) => { + return (URLSearchParams.prototype as any)[p].call(target, name); + }; + default: + return Reflect.get(target, p, receiver); + } + }, + }); + } +} +{ + // Deliberately exercises only the trap's explicitly-handled cases + // (append/set/delete/has/getAll) — the same subset node-fetch's real + // Headers.js switch covers. Its `.get()`/`.toString()` are separate own + // CLASS METHODS that delegate to `getAll` rather than falling through the + // trap's `default: Reflect.get(target, p, receiver)` arm, because Node's + // native `URLSearchParams.prototype.get`/`.toString`, called with `this` + // bound to the Proxy receiver (as `default` would do), rejects a Proxy + // `this` via its own internal-slot brand check — a genuine, unrelated + // Node quirk this fixture avoids by construction, not a Perry gap. + const headers: any = new FetchLikeHeaders(); + console.log("headers-before-has-accept:", headers.has("Accept")); + headers.set("Accept", "*/*"); + console.log("headers-after-has-accept:", headers.has("Accept")); + headers.append("X-Extra", "1"); + headers.append("X-Extra", "2"); + console.log("headers-getall:", headers.getAll("X-Extra").join(",")); + headers.delete("X-Extra"); + console.log("headers-after-delete:", headers.has("X-Extra")); +} + +// ---- Object.prototype methods must also be present on URLSearchParams.prototype +// (installed alongside the URLSearchParams-specific set, same as every other +// builtin's arm in populate_builtin_prototype_methods). ---- +{ + const proto: any = URLSearchParams.prototype; + console.log( + "object-proto-methods:", + typeof proto.hasOwnProperty, + typeof proto.isPrototypeOf, + typeof proto.propertyIsEnumerable, + ); +} From 0f8e698f5847b61d5ac2947e3696e6990ac50a9c Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Sun, 20 Sep 2026 12:54:39 +0000 Subject: [PATCH 2/2] changelog: fragment for #10807 (URLSearchParams prototype value-read fix) --- .../10807-urlsearchparams-prototype-value-reads.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 changelog.d/10807-urlsearchparams-prototype-value-reads.md diff --git a/changelog.d/10807-urlsearchparams-prototype-value-reads.md b/changelog.d/10807-urlsearchparams-prototype-value-reads.md new file mode 100644 index 0000000000..b61fb00366 --- /dev/null +++ b/changelog.d/10807-urlsearchparams-prototype-value-reads.md @@ -0,0 +1,13 @@ +### Fixed + +- **`URLSearchParams.prototype` methods read as a value now resolve to real, + callable closures.** `URLSearchParams.prototype.append`, `.prototype["has"]`, + and the same reads through a Proxy `get` trap previously returned + `undefined`, so `Function.prototype.call`/`.apply` on the result threw + `Function.prototype.call was called on a value that is not a function`. + node-fetch@3.3.2's `Headers extends URLSearchParams` hits this via a + constructor-returned `Proxy` whose `get` trap does exactly this, on every + `fetch()` call. The six other `#10555`-group members + (`AbortController`, `AbortSignal`, `CustomEvent`, `Event`, `EventTarget`, + `URL`) have the identical defect and remain unfixed — see #10807's PR body + for the audit. (#10759)