Skip to content

fix(codegen): class-expression statics — run static blocks per evaluation, bind static this to the fresh object - #6269

Merged
proggeramlug merged 2 commits into
mainfrom
fix/1787-class-expr-static-this-blocks
Jul 11, 2026
Merged

fix(codegen): class-expression statics — run static blocks per evaluation, bind static this to the fresh object#6269
proggeramlug merged 2 commits into
mainfrom
fix/1787-class-expr-static-this-blocks

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Two halves of the same #685 / #1787 gap: a class expression evaluated inside a function (return class { … } factories — effect's make(), webpack/turbopack factory modules) lowers to a fresh heap class object per evaluation (ClassExprFresh), but its statics were second-class:

1. static { … } blocks never ran on the fresh path

The ClassExprFresh codegen arm emitted named static fields and __perry_ctor_caps, but not the __perry_static_init_* invocations (only the shared-template path emitted those) — and the module-init fallback can't run them correctly anyway (wrong time, wrong this, wrong captures). So return class { static { this.viaBlock = tag } } factories produced objects whose blocks simply never ran.

Fix (in the ClassExprFresh arm, expr/static_field_meta.rs):

  • register this evaluation's captured values as the template's CLASS_CAPTURE_VALUES snapshot (js_class_register_capture_values) right after building __perry_ctor_caps — compiled static-method bodies resolve enclosing-scope reads through the name-keyed snapshot (js_param_or_class_capture_value), not the ctor-caps array;
  • after the symbol statics, invoke each of the template's __perry_static_init_* methods, arming the one-shot static-this override (js_static_this_arm_value(obj)) before each call, so the body's js_static_this_resolve prologue binds this to this fresh object — block writes land as its own properties.

2. Static methods called on the fresh object bound this to the shared template

The compile-time static-dispatch tower (devirtualized make(a).m() / const C = make(a); C.m() via func_returns_class) armed the receiver only for plain ClassRef receivers; dynamic-value receiver shapes (Call / LocalGet / ClassExprFresh) only set implicit this — which the static-method prologue never reads (it consumes the armed override or falls back to the lexical class-ref). So this.<field> read the template's static-field global — which the fresh path never writes — and returned undefined.

Fix (lower_call/property_get/static_dispatch.rs): always arm the actual receiver box. For a local holding a plain ClassRef value this arms exactly the prologue's default (no behavior change); for fresh objects it restores the receiver — matching the runtime dispatch tower (js_class_static_method_call), which has armed its receiver since the static-private-brand work.

Validation

  • Class gap regression: 13/13 pass (was 11/13) — flips the two long-standing failures test_gap_class_expr_static_this and test_gap_class_expr_extends_static_call_this (previously every viaThis() returned undefined).
  • New gap test test_gap_class_expr_fresh_static_blocks_this.ts — blocks per evaluation with per-evaluation captures, this === receiver identity, const-bound + inline receivers, nested-declaration and top-level-expression controls — byte-identical to node.
  • Known limitation (pre-existing, documented in-code): on the fresh path, blocks run after the named fields; source interleaving of fields and blocks is not reproduced.
  • cargo fmt clean.

Code-only; no version/changelog bump (maintainer folds metadata at merge).

Summary by CodeRabbit

  • New Features

    • Static initialization blocks now run when class expressions are evaluated.
    • Static blocks and methods correctly bind this to the newly created class object.
    • Class expressions preserve captured outer values for use during static initialization.
  • Bug Fixes

    • Improved consistency for static dispatch across different class expression and factory evaluation patterns.
    • Ensured per-evaluation static fields and initialization behavior work correctly for fresh class objects.

…tion, bind static `this` to the fresh object

Two halves of the same #685 / #1787 gap: a class EXPRESSION evaluated inside a
function (`return class { … }` factories — effect's `make()`, webpack/turbopack
factory modules) lowers to a fresh heap class object per evaluation
(`ClassExprFresh`), but its statics were second-class:

1. `static { … }` blocks NEVER ran on the fresh path. The `ClassExprFresh`
   codegen arm emitted named static fields and `__perry_ctor_caps`, but not the
   `__perry_static_init_*` invocations (only the shared-template path emitted
   those), and the module-init fallback can't run them correctly anyway (wrong
   time, wrong `this`, wrong captures). Fix, in the `ClassExprFresh` arm:
     - register this evaluation's captured values as the template's
       CLASS_CAPTURE_VALUES snapshot (`js_class_register_capture_values`)
       right after building `__perry_ctor_caps` — the compiled static-method
       bodies resolve enclosing-scope reads through the name-keyed snapshot
       (`js_param_or_class_capture_value`), not the ctor-caps array;
     - after the symbol statics, invoke each of the template's
       `__perry_static_init_*` methods, arming the one-shot static-`this`
       override (`js_static_this_arm_value(obj)`) before each call so the
       body's `js_static_this_resolve` prologue binds `this` to THIS fresh
       object — block writes land as its own properties.
   Blocks run after the named fields; the source interleaving of fields and
   blocks is not reproduced on this path (pre-existing limitation).

2. A static METHOD called on the fresh object bound `this` to the shared
   TEMPLATE. The compile-time static-dispatch tower (devirtualized
   `make(a).m()` / `const C = make(a); C.m()` via `func_returns_class`)
   armed the receiver only for plain `ClassRef` receivers; dynamic-value
   receiver shapes (`Call` / `LocalGet` / `ClassExprFresh`) only set implicit
   `this` — which the static-method prologue NEVER reads (it consumes the
   armed override or falls back to the lexical class-ref). So `this.<field>`
   read the template's static-field global — which the fresh path never
   writes — and returned `undefined`. Fix: always arm the actual receiver
   box. For a local holding a plain ClassRef value this arms exactly the
   prologue's default (no behavior change); for fresh objects it restores
   the receiver — matching the runtime dispatch tower
   (`js_class_static_method_call`), which has armed its receiver since the
   static-private-brand work.

Fixes the two long-standing gap-test failures
`test_gap_class_expr_static_this` and
`test_gap_class_expr_extends_static_call_this` (previously DIFF: every
`viaThis()` returned undefined). Class gap regression: 13/13 pass (was
11/13). New gap test `test_gap_class_expr_fresh_static_blocks_this.ts`
(blocks per evaluation + captures, `this` identity, const-bound and inline
receivers, nested-declaration and top-level-expression controls) —
byte-identical to node.
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Fresh class expressions now register captured outer values, execute template static initializer blocks for each evaluation, and bind static this to the fresh class object. Tests cover per-evaluation initialization, static method identity, nested classes, and module-level execution.

Changes

Fresh class static initialization

Layer / File(s) Summary
Capture snapshot registration
crates/perry-codegen/src/expr/static_field_meta.rs
Fresh class lowering collects captured values and registers them in the template capture-value table.
Static initializer execution
crates/perry-codegen/src/expr/static_field_meta.rs, crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs
Fresh class evaluation invokes compiled static initializers and arms static this for all supported receiver shapes.
Behavior validation
test-files/test_gap_class_expr_fresh_static_blocks_this.ts
Tests verify per-evaluation static blocks, static method this, identity, nested classes, and top-level initialization.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClassExprFresh
  participant StaticThisRuntime
  participant StaticInitializer
  participant FreshClass
  ClassExprFresh->>FreshClass: allocate class object
  ClassExprFresh->>StaticThisRuntime: arm fresh class as static this
  StaticThisRuntime->>StaticInitializer: bind this
  ClassExprFresh->>StaticInitializer: invoke static initializer
  StaticInitializer->>FreshClass: update static fields
Loading

Possibly related PRs

  • PerryTS/perry#5110: Implements related static initializer execution and class this binding for another class evaluation context.
  • PerryTS/perry#5604: Fixes capture snapshot selection used by class-expression captured values.
  • PerryTS/perry#5662: Adds related runtime registration of captured values for class-reference lowering.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main codegen fix for class-expression statics and fresh-object static-this binding.
Description check ✅ Passed The description covers the summary, concrete changes, and validation, though it omits explicit Related issue and Test plan checklist sections.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1787-class-expr-static-this-blocks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/perry-codegen/src/expr/static_field_meta.rs (1)

441-473: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Ordering is correct; capture-snapshot emission duplicates RegisterClassCaptures.

The snapshot registration runs before the static { … } blocks below (write-right-before-use), and the non-empty/template_cid != 0 guards match the upstream js_class_register_capture_values contract — good.

Lines 451-472 are near-identical to the buffer-build + register logic in the RegisterClassCaptures arm (Lines 155-177). Consider extracting a small helper to keep the two capture-snapshot sites from drifting.

♻️ Suggested helper to share the snapshot emission
// free fn in this module
fn emit_capture_snapshot(ctx: &mut FnCtx<'_>, cid_str: &str, values: &[String]) {
    let n = values.len();
    if n == 0 {
        return;
    }
    let buf = ctx.func.alloca_entry_array(DOUBLE, n);
    for (i, v) in values.iter().enumerate() {
        let slot = ctx
            .block()
            .gep(DOUBLE, &buf, &[(crate::types::I64, &i.to_string())]);
        ctx.block().store(DOUBLE, v, &slot);
    }
    let ptr_reg = ctx.block().next_reg();
    ctx.block()
        .emit_raw(format!("{} = getelementptr [{} x double], ptr {}, i64 0, i64 0", ptr_reg, n, buf));
    ctx.block().call_void(
        "js_class_register_capture_values",
        &[
            (crate::types::I32, cid_str),
            (crate::types::PTR, &ptr_reg),
            (crate::types::I64, &n.to_string()),
        ],
    );
}

Then both arms become emit_capture_snapshot(ctx, &cid_str, &lowered_caps); (guarded on template_cid != 0).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/expr/static_field_meta.rs` around lines 441 - 473,
Extract the duplicated capture-snapshot buffer construction and registration
logic from the RegisterClassCaptures arm and the template static-block path into
a shared emit_capture_snapshot helper in this module. Have it accept the
context, class ID string, and lowered capture values, return early for empty
values, and preserve the existing allocation, GEP, store, and
js_class_register_capture_values behavior; replace both inline implementations
with the helper while retaining the template_cid != 0 guard where applicable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-codegen/src/expr/static_field_meta.rs`:
- Around line 441-473: Extract the duplicated capture-snapshot buffer
construction and registration logic from the RegisterClassCaptures arm and the
template static-block path into a shared emit_capture_snapshot helper in this
module. Have it accept the context, class ID string, and lowered capture values,
return early for empty values, and preserve the existing allocation, GEP, store,
and js_class_register_capture_values behavior; replace both inline
implementations with the helper while retaining the template_cid != 0 guard
where applicable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3708a287-d13c-4432-a9c8-7506201194ff

📥 Commits

Reviewing files that changed from the base of the PR and between 77ea179 and 0eabcc1.

📒 Files selected for processing (3)
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs
  • test-files/test_gap_class_expr_fresh_static_blocks_this.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant