Skip to content

for...of on Map obtained via local alias of class field yields incorrect keys / zero iterations #305

Closed

Description

@codehz

What happened

as title

What you expected

it should works as usual

Minimal reproduction

// ============================================================
// BUG 2: for...of on Map obtained via local alias of class field
//         yields incorrect keys / zero iterations
// ============================================================
// When a class-field Map<BrandedType, T> is assigned to a local variable
// and iterated, Perry uses a wrong iterator/key representation.
//
// Impact in @codehz/ecs:
//   In CommandBuffer.execute():
//     const entityCommands = this.entityCommands;  // class field Map<EntityId, Command[]>
//     for (const cmd of currentCommands) {
//       entityCommands.set(cmd.entityId, [cmd]);   // key appears as 0 not entity ID
//     }
//     for (const [entityId, commands] of entityCommands) {  // yields 0 entries
//       this.executeEntityCommands(entityId, commands);      // never called!
//     }
//   Because executeEntityCommands is never called, world.sync() is a no-op,
//   and world.has(child, relation) returns false.

class Example {
  private map: Map<number, string> = new Map();

  runViaLocal(key: number): void {
    const m = this.map; // local alias – BUG triggers here
    m.set(key, "hello");

    const keys = Array.from(m.keys());
    let iterCount = 0;
    for (const [k, v] of m) {
      iterCount++;
    }

    console.log("=== via local alias ===");
    console.log(`map.size:          ${m.size}`); // expected 1
    console.log(`keys:              ${JSON.stringify(keys)}`); // expected [1025]
    console.log(`for..of count:     ${iterCount}`); // expected 1
    console.log(`bug (wrong keys):  ${JSON.stringify(keys) !== "[1025]"}`);
    console.log(`bug (no iters):    ${iterCount !== 1}`);
    this.map.clear();
  }

  runViaDirect(key: number): void {
    this.map.set(key, "hello");

    const keys = Array.from(this.map.keys());
    let iterCount = 0;
    for (const [k, v] of this.map) {
      iterCount++;
    }

    console.log("\n=== via this.map directly ===");
    console.log(`map.size:          ${this.map.size}`);
    console.log(`keys:              ${JSON.stringify(keys)}`);
    console.log(`for..of count:     ${iterCount}`);
    console.log(`bug (wrong keys):  ${JSON.stringify(keys) !== "[1025]"}`);
    console.log(`bug (no iters):    ${iterCount !== 1}`);
    this.map.clear();
  }
}

const ex = new Example();
ex.runViaLocal(1025);
ex.runViaDirect(1025);

Command you ran:

perry test.ts && ./test

Environment

  • Perry version: 0.5.390
  • Host OS: ArchLinux
  • Target: native
  • Rust toolchain (if building from source): rustc 1.95.0 (59807616e 2026-04-14)
  • Installed via: from source

Diagnostic output

Perry Doctor

Environment Checks
──────────────────
✓ perry version: 0.5.390
✓ update status: up to date
✓ clang (LLVM codegen): clang version 22.1.3
✓ system linker (cc): cc (GCC) 15.2.1 20260209
✓ runtime library: /home/codehz/Projects/perry/target/release/libperry_runtime.a
⚠ project config (perry.toml): not found - run: perry init

All critical checks passed with some warnings.

Anything else

found in my ecs's demo

Activity

  1. added
    bugConfirmed defect or regression
    on Apr 30, 2026
  2. added a commit that references this issue on Apr 30, 2026
  3. proggeramlug commented on Apr 30, 2026

    @proggeramlug
    Contributor

    Fixed in v0.5.397. const m = this.map; for-of m now iterates correctly because the this.<field> shape is now resolved through the class_field_types registry (originally landed in v0.5.388 for #302).

    The same fix transparently covers const s = this.items (Set field) and const a = this.xs (number[] field) — any local alias of a typed class field now inherits the correct generic / array element type so for-of and method dispatch take the right fast path.

    Regression test: test-files/test_issue_305_local_alias_class_field_map.ts (4 cases — Map local alias, Map this.x direct comparison, Set local alias, Array local alias).

  4. added a commit that references this issue on Apr 30, 2026
  5. codehz commented on Apr 30, 2026

    @codehz
    Author

    new repro in 0.5.415

    // Minimal reproduction for perry bug:
    // `for-of` on a Map stored in a class field does not iterate.
    //
    // Root cause: perry does not expose Map.prototype[Symbol.iterator] when the Map
    // is accessed through `this` (class field access). As a result, the generated
    // native code cannot find the iterator and silently skips the loop body.
    //
    // Workaround: use `.entries()` / `.keys()` / `.values()` explicitly.
    //
    // Impact on @codehz/ecs: CommandBuffer.execute() contains
    //
    //   for (const [entityId, commands] of entityCommands) { ... }
    //
    // where `entityCommands` is a class-field Map. Because the loop body never
    // runs, every command is silently dropped and world.sync() becomes a no-op,
    // making world.has() / world.get() return wrong results after world.set().
    
    // ── Repro 1: for-of directly on a class-field Map ────────────────────────
    class MapHolder {
      private map = new Map<number, string>();
    
      add(k: number, v: string) {
        this.map.set(k, v);
      }
    
      iterate() {
        console.log(`map.size = ${this.map.size}`);
        // Bug: loop body never executes even though size = 2
        console.log("for-of this.map (default iterator):");
        for (const [k, v] of this.map) {
          console.log(`  k=${k}, v=${v}`);
        }
        // Workaround: explicit .entries() works fine
        console.log("for-of this.map.entries() (workaround):");
        for (const [k, v] of this.map.entries()) {
          console.log(`  k=${k}, v=${v}`);
        }
      }
    }
    
    const holder = new MapHolder();
    holder.add(1, "hello");
    holder.add(2, "world");
    holder.iterate();
    
    // ── Repro 2: local variable assigned from class-field Map ─────────────────
    // Assigning to a local variable does NOT restore proper iteration –
    // perry appears to track the "this-access" origin of the value.
    class LocalAlias {
      private map = new Map<number, string>();
    
      add(k: number, v: string) {
        this.map.set(k, v);
      }
    
      iterate() {
        const local = this.map;
        console.log(`local.size = ${local.size}`);
        console.log("for-of local (assigned from this.map):");
        for (const [k, v] of local) {
          console.log(`  k=${k}, v=${v}`); // NEVER prints in perry
        }
      }
    }
    
    const la = new LocalAlias();
    la.add(10, "foo");
    la.add(20, "bar");
    la.iterate();
    
    // ── Control: module-scope Map (works correctly) ───────────────────────────
    const moduleMap = new Map<number, string>();
    moduleMap.set(100, "a");
    moduleMap.set(200, "b");
    console.log("for-of moduleMap (module-scope, control):");
    for (const [k, v] of moduleMap) {
      console.log(`  k=${k}, v=${v}`); // prints correctly
    }
    
    // ── Control: local variable inside a plain function (works correctly) ──────
    function localMapTest() {
      const m = new Map<number, string>();
      m.set(1000, "x");
      m.set(2000, "y");
      console.log("for-of local map inside function (control):");
      for (const [k, v] of m) {
        console.log(`  k=${k}, v=${v}`); // prints correctly
      }
    }
    localMapTest();
    
    // ── Symptom: Symbol.iterator is undefined on class-field Map ──────────────
    class IterCheck {
      private map = new Map<number, string>();
      run() {
        const m = this.map as any;
        console.log(`typeof this.map[Symbol.iterator] = ${typeof m[Symbol.iterator]}`);
        // Expected: "function"
        // Perry:    "undefined"
      }
    }
    new IterCheck().run();
    
    // ─────────────────────────────────────────────────────────────────────────────
    // Expected output (bun / node):
    //
    //   map.size = 2
    //   for-of this.map (default iterator):
    //     k=1, v=hello
    //     k=2, v=world
    //   for-of this.map.entries() (workaround):
    //     k=1, v=hello
    //     k=2, v=world
    //   local.size = 2
    //   for-of local (assigned from this.map):
    //     k=10, v=foo
    //     k=20, v=bar
    //   for-of moduleMap (module-scope, control):
    //     k=100, v=a
    //     k=200, v=b
    //   for-of local map inside function (control):
    //     k=1000, v=x
    //     k=2000, v=y
    //   typeof this.map[Symbol.iterator] = function
    //
    // Actual output in perry (compiled native):
    //
    //   map.size = 2
    //   for-of this.map (default iterator):          <- body skipped entirely
    //   for-of this.map.entries() (workaround):
    //     k=1, v=hello
    //     k=2, v=world
    //   local.size = 2
    //   for-of local (assigned from this.map):       <- body skipped entirely
    //   for-of moduleMap (module-scope, control):
    //     k=100, v=a
    //     k=200, v=b
    //   for-of local map inside function (control):
    //     k=1000, v=x
    //     k=2000, v=y
    //   typeof this.map[Symbol.iterator] = undefined <- should be "function"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions