Skip to content

aggregate_scalar deletes exported arrays and leaves undefined getters #9053

Description

@proggeramlug

Summary

The aggregate-scalar transform can delete an exported array of object literals because cross-module consumers are invisible to its escape analysis. Perry then emits an export getter that returns undefined.

This caused an application to fail in for (const item of ALLEYS) with TypeError: is not iterable; the reported source location pointed at an unrelated earlier call.

Reproduced on e81548ed17ff9488c3f8c19256048dc8d8497867. The transform is unchanged on main at bbf1bcf00921b1d15fec77866bd2f5f9fbfdff95.

Minimal two-module reproduction

values.js:

export const VALUES = [
  { x: 1, label: "one" },
  { x: 2, label: "two" },
];

main.ts:

import { VALUES } from "./values.js";

console.log(VALUES.length);
for (const value of VALUES) {
  console.log(value.x, value.label);
}

Compile and run normally:

perry compile main.ts -o main
./main

Actual behavior

VALUES.length is undefined, followed by:

TypeError: is not iterable

In the larger reproducer, disassembly of the generated zero-argument export getter showed that it returns TAG_UNDEFINED unconditionally.

Post-transform HIR contains synthetic locals such as:

__perry_scalar_aggregate_<array-id>_<element>_<property>

but no longer contains the top-level Let for the exported carrier array.

Expected behavior

2
1 one
2 two

The exported array's identity, length, iteration behavior, and elements are externally observable and must be materialized.

Root cause

crates/perry-transform/src/aggregate_scalar.rs counts references across HIR regions in the current module. An imported use lives in another module and therefore contributes no LocalGet reference to the producer's analysis.

For an exported array unused inside its declaring module, scalarize_candidate concludes that the carrier does not escape, replaces its object fields with scalar locals, and removes the carrier declaration. Export metadata is not currently treated as an escape.

Proposed fix

Treat exported bindings as externally observable scalarization barriers:

  1. Derive the local binding IDs exported by Module.exports / exported_objects before transforming module init.
  2. Pass those IDs into aggregate-scalar candidate analysis.
  3. Reject any candidate whose binding is exported, even if there are no same-module references.

Reconstructing the full value lazily in the getter would be substantially more complex and would still have to preserve identity, so retaining the carrier is the safe fix.

Regression coverage

  • Transform unit test asserting an exported aggregate carrier Let survives.
  • Two-module compile/run test using the reproduction above.
  • Assert .length, iteration, element property reads, and repeated import reads preserve the same array identity.
  • Keep an equivalent non-exported/local aggregate test to ensure scalar replacement still fires where the carrier is genuinely non-escaping.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regressionparityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions