Perry version: 0.5.1161
Summary
Calling a method inline on a property access of an array/object element — expr.prop.method(arg) — miscompiles: the method runs against the wrong receiver and returns a wrong result. Extracting the property into a local variable first makes it correct.
This silently breaks common code like arr[i].id.indexOf(x) and Array.prototype.filter(p => p.id.indexOf(x) >= 0) (the filter predicate is the same shape internally), with no error — the call just returns the wrong value.
Minimal repro
interface P { id: string }
const arr: P[] = [{ id: "GSC_PRO_MONTHLY" }, { id: "GSC_AGENCY_MONTHLY" }, { id: "GSC_PRO_ANNUAL" }]
// A — inline property-access chained into a method call
let a = 0
for (let i = 0; i < arr.length; i++) {
if (arr[i].id.indexOf("PRO") >= 0) a++
}
console.log("A inline =", a) // prints 0 ❌ (expected 2)
// B — extract the property to a local first
let b = 0
for (let i = 0; i < arr.length; i++) {
const pid: string = arr[i].id
if (pid.indexOf("PRO") >= 0) b++
}
console.log("B extracted =", b) // prints 2 ✅
// Also affected — filter with the same predicate shape:
console.log("filter =", arr.filter(function (p: P) { return p.id.indexOf("PRO") >= 0 }).length) // 0 ❌ (expected 2)
// NOT affected — these work:
console.log("map =", arr.map(function (p: P) { return p.id }).join(",")) // correct ids
console.log("filter-true =", arr.filter(function (p: P) { return true }).length) // 3 ✅
console.log("num =", [1,2,3,4].filter(function (n: number) { return n > 2 }).length) // 2 ✅
Run on the native host target (perry run repro.ts): A prints 0, B prints 2.
Observations
- The bug is the chaining (
<objectExpr>.prop.method(...)), not indexOf itself or .filter itself:
localString.indexOf("PRO") works.
const pid = arr[i].id; pid.indexOf("PRO") works.
arr[i].id.indexOf("PRO") / p.id.indexOf("PRO") (inline) returns wrong.
.map(p => p.id) returns the correct property values, so reading p.id alone is fine — it's specifically reading a property and immediately invoking a method on it that breaks. Looks like the method receiver is bound to the object (arr[i] / p) rather than the property value (.id), or the property load is dropped before the call.
- Reproduces on the native host (macOS) target, so it's codegen/runtime, not platform-specific.
Impact
Silent wrong results in extremely common code shapes (item.name.startsWith(...), row.url.indexOf(...), obj.field.toLowerCase(), and any arr.filter(x => x.prop.method())). Downstream (GSC Master) this made a StoreKit product lookup (products.filter(p => p.id.indexOf("PRO") >= 0)) silently return empty, so the in-app purchase button reported the plan as unavailable. Worked around by extracting the property to a local before the method call.
Perry version: 0.5.1161
Summary
Calling a method inline on a property access of an array/object element —
expr.prop.method(arg)— miscompiles: the method runs against the wrong receiver and returns a wrong result. Extracting the property into a local variable first makes it correct.This silently breaks common code like
arr[i].id.indexOf(x)andArray.prototype.filter(p => p.id.indexOf(x) >= 0)(the filter predicate is the same shape internally), with no error — the call just returns the wrong value.Minimal repro
Run on the native host target (
perry run repro.ts): A prints0, B prints2.Observations
<objectExpr>.prop.method(...)), notindexOfitself or.filteritself:localString.indexOf("PRO")works.const pid = arr[i].id; pid.indexOf("PRO")works.arr[i].id.indexOf("PRO")/p.id.indexOf("PRO")(inline) returns wrong..map(p => p.id)returns the correct property values, so readingp.idalone is fine — it's specifically reading a property and immediately invoking a method on it that breaks. Looks like the method receiver is bound to the object (arr[i]/p) rather than the property value (.id), or the property load is dropped before the call.Impact
Silent wrong results in extremely common code shapes (
item.name.startsWith(...),row.url.indexOf(...),obj.field.toLowerCase(), and anyarr.filter(x => x.prop.method())). Downstream (GSC Master) this made a StoreKit product lookup (products.filter(p => p.id.indexOf("PRO") >= 0)) silently return empty, so the in-app purchase button reported the plan as unavailable. Worked around by extracting the property to a local before the method call.