diff --git a/changelog.d/7127-in-operator-to-property-key.md b/changelog.d/7127-in-operator-to-property-key.md new file mode 100644 index 0000000000..293eec64fd --- /dev/null +++ b/changelog.d/7127-in-operator-to-property-key.md @@ -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. diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 5e83c68688..36985b9ad8 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -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()); diff --git a/test-files/test_gap_in_operator_to_property_key.ts b/test-files/test_gap_in_operator_to_property_key.ts new file mode 100644 index 0000000000..c6e7cf64df --- /dev/null +++ b/test-files/test_gap_in_operator_to_property_key.ts @@ -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(","));