Skip to content

fix(hir): run static field initializers + static blocks for in-function classes#5110

Merged
proggeramlug merged 1 commit into
mainfrom
fix/class-static-init-in-function
Jun 14, 2026
Merged

fix(hir): run static field initializers + static blocks for in-function classes#5110
proggeramlug merged 1 commit into
mainfrom
fix/class-static-init-in-function

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Problem

A class declared inside a function body lost all static initialization — static x = … fields and static { … } blocks silently stayed at their zero default. Only top-level classes initialized.

function build() {
  class D { static a = 7; static b = 3*4; static c: number; static { D.c = D.a + D.b; } }
  return `${D.a},${D.b},${D.c}`;
}
build();   // node: "7,12,19"   perry (before): "0,0,0"

(Reproduces for both function bodies and arrow IIFEs.)

Root cause

The in-function class-decl lowering path (lower_decl/body_stmt.rs) emitted the class plus its parent / computed-member / capture registrations, but — unlike the module-level path in lower/stmt.rs — never emitted the static-field-init (StaticFieldSet / ClassStaticSymbolSet) or static-block-call (StaticMethodCall __perry_static_init_N) statements. So the synthetic static-init methods were never invoked and field initializers never ran.

Fix

Mirror the top-level emission in body_stmt.rs: after the class is set up, push the static-field initializers (with lexical this in the initializer bound to the class ref) then the static-block calls into the function body, in source order per ClassDefinitionEvaluation.

Verification (vs Node v26.3.0)

  • in-function class: 7,12,19 (was 0,0,0); arrow-IIFE static block: 99 (was 0); top-level unchanged (7,12,19).
  • New test-files/test_class_static_in_function.ts matches Node byte-for-byte.
  • cargo test -p perry-hir passes; existing class test-files (test_class_static_iife, test_class_static_symbol, test_edge_class_advanced, test_gap_*_static_helpers) all compile + run clean.

Found via a node --experimental-strip-types differential sweep. No version bump / changelog per maintainer instruction.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed classes declared inside functions so their static field initializers and static blocks run correctly during declaration evaluation, including computed and symbol-keyed fields.
  • Tests
    • Added a regression test covering in-function class declarations (including inside an arrow IIFE) to verify static initialization behavior end-to-end.

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 36139c01-481b-4920-aadd-d3ef496044be

📥 Commits

Reviewing files that changed from the base of the PR and between f1357f8 and c3bd051.

📒 Files selected for processing (2)
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • test-files/test_class_static_in_function.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test-files/test_class_static_in_function.ts

📝 Walkthrough

Walkthrough

lower_body_stmt gains logic to emit static field initializer evaluation and __perry_static_init_* method calls for classes declared inside function bodies. A new TypeScript regression test verifies this behavior using classes with static fields and static blocks inside both a named function and an arrow IIFE.

Changes

Nested class static field and block initialization

Layer / File(s) Summary
Static init emission in lower_body_stmt
crates/perry-hir/src/lower_decl/body_stmt.rs
Within the non-duplicate class-declaration lowering path, iterates class.static_fields, substitutes lexical this with a ClassRef, and emits ClassStaticSymbolSet for computed/symbol keys or StaticFieldSet for named fields. Then scans class.static_methods and emits StaticMethodCall for any method prefixed with __perry_static_init_.
Regression test
test-files/test_class_static_in_function.ts
Declares classes with static fields and static blocks inside build() and an arrow IIFE, then asserts computed static values using console.log, with a top-level class T as a control.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 A class inside a function, how sly!
Static fields once slept — now they fly.
__perry_static_init_ gets called just right,
ClassRef stands in for this in the night.
The rabbit hops in, checks the log's reply,
All values match — hip-hop hooray! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(hir): run static field initializers + static blocks for in-function classes' clearly and specifically summarizes the main change—enabling static initialization for classes declared inside functions.
Description check ✅ Passed The description comprehensively covers the problem, root cause, fix, and verification; follows the template structure with clear sections; and includes test evidence and Node.js comparison.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/class-static-init-in-function

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

@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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/perry-hir/src/lower_decl/body_stmt.rs (1)

315-345: ⚡ Quick win

Consider extracting shared static-init emission logic.

The static field and block emission sequence (lines 315-345) duplicates the top-level path in crates/perry-hir/src/lower/stmt.rs lines 1060-1135. Both iterate static_fields, substitute lexical this, emit ClassStaticSymbolSet/StaticFieldSet, then call __perry_static_init_* methods.

Extracting this into a shared helper (e.g., emit_class_static_initializers(class: &Class, stmts: &mut Vec<Stmt>)) would reduce the ~70-line duplication and ensure both paths stay synchronized if the emission logic changes.

🤖 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-hir/src/lower_decl/body_stmt.rs` around lines 315 - 345, Extract
the static initialization emission logic into a shared helper function to
eliminate the ~70-line duplication between the two static-init emission paths.
Create a helper function (e.g., emit_class_static_initializers) that accepts a
Class reference and a mutable Vec<Stmt>, then performs the sequence of iterating
static_fields, calling substitute_lexical_this_in_expr, emitting
ClassStaticSymbolSet or StaticFieldSet expressions based on whether key_expr
exists, and iterating static_methods to emit StaticMethodCall expressions for
methods matching the __perry_static_init_ pattern. Replace the duplicated block
in the current location with a call to this helper, and apply the same
refactoring to the duplicate logic in the other file to ensure both code paths
remain synchronized.
🤖 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.

Inline comments:
In `@crates/perry-runtime/src/value/dynamic_arith.rs`:
- Around line 105-123: The code casts ptr to ObjectHeader and immediately calls
try_read_as_search_params on it without validating that ptr is actually a valid
object pointer. Small pointers (value < 0x100000) are widget handles, not object
pointers, and dereferencing them causes memory safety issues. Before the if
block that calls crate::url::try_read_as_search_params(obj).is_some(), add a
guard to check that ptr is not a small pointer (ensure ptr >= 0x100000) to
prevent invalid memory reads when non-object pointer kinds reach this code path
during + coercion.

---

Nitpick comments:
In `@crates/perry-hir/src/lower_decl/body_stmt.rs`:
- Around line 315-345: Extract the static initialization emission logic into a
shared helper function to eliminate the ~70-line duplication between the two
static-init emission paths. Create a helper function (e.g.,
emit_class_static_initializers) that accepts a Class reference and a mutable
Vec<Stmt>, then performs the sequence of iterating static_fields, calling
substitute_lexical_this_in_expr, emitting ClassStaticSymbolSet or StaticFieldSet
expressions based on whether key_expr exists, and iterating static_methods to
emit StaticMethodCall expressions for methods matching the __perry_static_init_
pattern. Replace the duplicated block in the current location with a call to
this helper, and apply the same refactoring to the duplicate logic in the other
file to ensure both code paths remain synchronized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a0a8174f-f460-4b8b-95e5-5e1cc4ad7be0

📥 Commits

Reviewing files that changed from the base of the PR and between 2cfa8ca and b3ecc91.

📒 Files selected for processing (5)
  • crates/perry-codegen/src/linker.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry-runtime/src/value/dynamic_arith.rs
  • crates/perry-runtime/src/value/to_string.rs
  • test-files/test_class_static_in_function.ts

Comment on lines +105 to +123
// WHATWG `URL` / `URLSearchParams` have native `toString`s (`href` / the
// query string) that OrdinaryToPrimitive can't see — it would resolve the
// inherited `Object.prototype.toString` and yield "[object Object]". Like
// the Date special-case above, pre-empt with the real string so
// `"" + url` / `` `${url}` `` match explicit `url.toString()` (#URL coercion).
{
let boxed =
f64::from_bits(crate::value::POINTER_TAG | ((ptr as u64) & crate::value::POINTER_MASK));
let href = crate::url::url_class::js_url_href_if_url(boxed);
if href.to_bits() != crate::value::TAG_UNDEFINED {
let s = js_jsvalue_to_string(href);
return crate::value::js_nanbox_string(s as i64);
}
let obj = ptr as *mut crate::object::ObjectHeader;
if crate::url::try_read_as_search_params(obj).is_some() {
let s = crate::url::search_params::js_url_search_params_to_string(obj);
return crate::value::js_nanbox_string(s as i64);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Guard URLSearchParams probing behind an object-pointer validity check

Line 118 casts ptr to ObjectHeader and immediately probes try_read_as_search_params, but that helper dereferences object fields. In this path, non-object pointer kinds can still reach this block, so this can become an invalid memory read/crash during + coercion.

💡 Suggested fix
     {
         let boxed =
             f64::from_bits(crate::value::POINTER_TAG | ((ptr as u64) & crate::value::POINTER_MASK));
         let href = crate::url::url_class::js_url_href_if_url(boxed);
         if href.to_bits() != crate::value::TAG_UNDEFINED {
             let s = js_jsvalue_to_string(href);
             return crate::value::js_nanbox_string(s as i64);
         }
-        let obj = ptr as *mut crate::object::ObjectHeader;
-        if crate::url::try_read_as_search_params(obj).is_some() {
-            let s = crate::url::search_params::js_url_search_params_to_string(obj);
-            return crate::value::js_nanbox_string(s as i64);
+        if crate::object::is_valid_obj_ptr(ptr as *const u8) {
+            let obj = ptr as *mut crate::object::ObjectHeader;
+            if crate::url::try_read_as_search_params(obj).is_some() {
+                let s = crate::url::search_params::js_url_search_params_to_string(obj);
+                return crate::value::js_nanbox_string(s as i64);
+            }
         }
     }

As per coding guidelines, "Detect small pointers (value < 0x100000) as widget handles in the NaN-boxed value representation".

🤖 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-runtime/src/value/dynamic_arith.rs` around lines 105 - 123, The
code casts ptr to ObjectHeader and immediately calls try_read_as_search_params
on it without validating that ptr is actually a valid object pointer. Small
pointers (value < 0x100000) are widget handles, not object pointers, and
dereferencing them causes memory safety issues. Before the if block that calls
crate::url::try_read_as_search_params(obj).is_some(), add a guard to check that
ptr is not a small pointer (ensure ptr >= 0x100000) to prevent invalid memory
reads when non-object pointer kinds reach this code path during + coercion.

Source: Coding guidelines

@proggeramlug proggeramlug force-pushed the fix/class-static-init-in-function branch 2 times, most recently from f1357f8 to 1fa77db Compare June 14, 2026 06:06
…on classes

A class declared inside a function body lost ALL static initialization:
`static x = …` fields and `static { … }` blocks silently stayed at their
zero default (`D.a,D.b,D.c` → `0,0,0` instead of `7,12,19`). Only
top-level classes initialized.

Root cause: the in-function class-decl lowering path
(`lower_decl/body_stmt.rs`) emitted the class plus its parent /
computed-member / capture registrations, but — unlike the module-level
path in `lower/stmt.rs` — never emitted the static-field-init
(`StaticFieldSet` / `ClassStaticSymbolSet`) or static-block-call
(`StaticMethodCall __perry_static_init_N`) statements into the function
body. So the synthetic static-init methods were never invoked and field
initializers never ran.

Fix: mirror the top-level emission in `body_stmt.rs` — after the class is
set up, push the static-field initializers (with lexical `this` in the
initializer bound to the class ref) then the static-block calls into the
function body, in source order per ClassDefinitionEvaluation.

Verified vs Node v26.3.0: in-function and arrow-IIFE classes now produce
`7,12,19` / `99` (was `0,0,0` / `0`); top-level classes unchanged. New
`test-files/test_class_static_in_function.ts` matches Node byte-for-byte;
perry-hir unit tests and existing class test-files pass.
@proggeramlug proggeramlug force-pushed the fix/class-static-init-in-function branch from 1fa77db to c3bd051 Compare June 14, 2026 07:33
@proggeramlug proggeramlug merged commit 0b8f6f0 into main Jun 14, 2026
15 checks passed
@proggeramlug proggeramlug deleted the fix/class-static-init-in-function branch June 14, 2026 08:57
proggeramlug pushed a commit that referenced this pull request Jun 14, 2026
Rolls up the issue-fix batch merged on top of 0.5.1165 (#5102, #5103,
#5105, #5106, #5107, #5108, #5109, #5110, #5112, #5117). See CHANGELOG
for the per-PR breakdown.
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