Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions changelog.d/7127-in-operator-to-property-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
fix(runtime): `in` operator runs `ToPropertyKey` for every key type (#6944)

`key in obj` only coerced NUMBER keys through `ToPropertyKey` (the arm added for Next.js's `Number(digest.at(-2)) in RedirectStatusCode`). Per spec, `RelationalExpression in ShiftExpression` is `ToPropertyKey(lval)` for **every** key type: an object key must have its `Symbol.toPrimitive` / `toString` / `valueOf` invoked — exactly once, and even when the property is absent, because the coercion is observable. Pre-fix the object key was compared as a raw pointer and never matched, and the user coercion never ran.

`js_object_has_property` now coerces every non-string, non-symbol key via `js_to_property_key` before the lookup. Strings (heap or SSO) and symbols are already property keys — the coercion is identity and allocates nothing — so they keep the pre-fix fast path verbatim. The coercion allocates and (for object keys) runs user JS, so it can GC and evacuate the receiver; the receiver is rooted across it via `RuntimeHandleScope::root_heap_word_u64` and read back through its handle, the same idiom as `js_object_get_property_key` / `js_object_set_property_key` (#6935) and the number arm's #6941 rooting.

New gap test `test-files/test_gap_in_operator_to_property_key.ts`, byte-identical to node 26.5.1: object keys via `toString` / `valueOf` fallback / `Symbol.toPrimitive`, exactly-once coercion on an absent property, object→Symbol coercion matching a symbol-keyed property, primitive keys (`true` / `null` / `undefined` / `1n`), int32-vs-f64 numeric-key agreement on a plain object, and a proxy `has` trap receiving the coerced key.
38 changes: 19 additions & 19 deletions crates/perry-runtime/src/object/field_get_set/has_property.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,33 +191,33 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 {

let obj_val = JSValue::from_bits(obj.to_bits());

// `in` runs ToPropertyKey on the key. Object property names are strings, so a
// NUMBER key must be coerced to its string form before the lookup — `307 in
// {307: …}` is `"307" in {…}` and must be true. Without this the string-only
// lookup below never matched a numeric key against a numeric-string property,
// so `307 in obj` was false while `"307" in obj` was true. Next.js's
// `isRedirectError` does `Number(digest.at(-2)) in RedirectStatusCode` (a
// `{307: …, 308: …}` map), so a `redirect()` thrown from a Server Component
// was not recognized as a redirect — Next treated it as a real error and a
// concurrently-rendered sibling's `session.user` read (guarded by that same
// redirect on the happy path) surfaced as a fatal 500 instead of a 307.
// (Symbols and strings pass through unchanged; a proxy/handle receiver is
// handled below with the coerced key.)
// `in` runs ToPropertyKey on the key for EVERY key type: per spec,
// `RelationalExpression in ShiftExpression` is `ToPropertyKey(lval)`, so an
// object key must have its `Symbol.toPrimitive` / `toString` / `valueOf`
// invoked — exactly once, and even when the property is absent, because the
// coercion is observable (#6944). Only strings (heap or SSO) and symbols
// are already property keys; for them the coercion is identity and
// allocates nothing, so it is skipped. Everything else goes through
// `js_to_property_key` before the lookup: numbers (`307 in {307: …}` is
// `"307" in {…}` — Next.js's `isRedirectError` does `Number(digest.at(-2))
// in RedirectStatusCode`), booleans, null, undefined, BigInt, and objects.
// (A proxy/handle receiver is handled below with the coerced key.)
//
// #6935: that coercion ALLOCATES the stringified key, so it can trigger a
// GC that evacuates the receiver — and `obj` / `obj_val` are raw locals
// captured above. (Only the number arm coerces, so no user JS runs here,
// but an allocation-triggered evacuation moves the receiver just the same.)
// #6935: that coercion ALLOCATES the stringified key, and for an object key
// it also runs user JS, so it can trigger a GC that evacuates the receiver
// — and `obj` / `obj_val` are raw locals captured above. Root the receiver
// across the coercion and read it back through the handle, mirroring
// `js_object_get_property_key` / `js_object_set_property_key`.
let (obj, obj_val, key) = {
let kv = JSValue::from_bits(key.to_bits());
if kv.is_number() {
if kv.is_any_string() || unsafe { crate::symbol::js_is_symbol(key) } != 0 {
(obj, obj_val, key)
} else {
let scope = crate::gc::RuntimeHandleScope::new();
let obj_handle = scope.root_heap_word_u64(obj.to_bits());
let key = unsafe { crate::object::js_to_property_key(key) };
let obj = f64::from_bits(obj_handle.get_heap_word_u64());
(obj, JSValue::from_bits(obj.to_bits()), key)
} else {
(obj, obj_val, key)
}
};
let key_val = JSValue::from_bits(key.to_bits());
Expand Down
95 changes: 95 additions & 0 deletions test-files/test_gap_in_operator_to_property_key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// #6944: the `in` operator must run ToPropertyKey on its left operand for
// EVERY key type, not just numbers. Per spec, `RelationalExpression in
// ShiftExpression` is `ToPropertyKey(lval)`, so an object key has its
// `Symbol.toPrimitive` / `toString` / `valueOf` invoked — exactly once, and
// even when the property is absent (the coercion is observable). Perry only
// coerced number keys, so an object key was compared as a raw pointer and
// never matched.

const obj: any = { here: 1 };

// object key with toString
const k: any = { toString(): string { return "here"; } };
console.log("object toString key :", (k as any) in obj);
console.log("string key :", "here" in obj);

// the coercion runs exactly once, even when the property is ABSENT
let calls = 0;
const absent: any = {
toString(): string {
calls++;
return "nope";
},
};
console.log("absent object key :", (absent as any) in obj);
console.log("coercion call count :", calls);

// Symbol.toPrimitive takes priority over toString/valueOf
const symPrim: any = {
[Symbol.toPrimitive](hint: string): string {
return "via-" + hint;
},
toString(): string {
return "via-toString";
},
};
const obj2: any = { "via-string": 1, "via-toString": 2 };
console.log("Symbol.toPrimitive :", (symPrim as any) in obj2);

// OrdinaryToPrimitive(string) tries toString first; a non-primitive toString
// result falls through to valueOf
const valOf: any = {
toString(): any {
return {};
},
valueOf(): number {
return 42;
},
};
const obj3: any = { 42: "answer" };
console.log("valueOf fallback :", (valOf as any) in obj3);

// toString wins over valueOf (string hint order)
const both: any = {
toString(): string {
return "str";
},
valueOf(): number {
return 99;
},
};
const obj4: any = { str: 1, 99: 2 };
console.log("toString > valueOf :", (both as any) in obj4);

// an object key coercing to a Symbol matches the symbol-keyed property
const sym = Symbol("s");
const symKey: any = {
toString(): any {
return sym;
},
};
const withSym: any = { [sym]: 1 };
console.log("object -> symbol key:", (symKey as any) in withSym);

// non-string primitives are stringified too
const primitives: any = { true: 1, null: 2, undefined: 3, "1": 4 };
console.log("boolean key :", (true as any) in primitives);
console.log("null key :", (null as any) in primitives);
console.log("undefined key :", (undefined as any) in primitives);
console.log("bigint key :", (1n as any) in primitives);

// int32-boxed and f64 numeric keys agree on a plain object
const small = 1;
console.log("int32 numeric key :", (small as any) in primitives);
console.log("f64 numeric key :", 1.0 in primitives);

// a proxy receiver observes the COERCED key in its `has` trap
const seen: string[] = [];
const proxy = new Proxy({ here: 1 }, {
has(target, prop) {
seen.push(String(prop));
return prop in target;
},
});
console.log("proxy has trap :", (k as any) in proxy);
console.log("proxy saw key :", seen.join(","));
Loading