-
-
Notifications
You must be signed in to change notification settings - Fork 161
fix(hir): forward inherited captures through a locally-shadowed class parent #10628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| ### Fixed | ||
|
|
||
| - **An inherited method of a capture-bearing class expression could read | ||
| `undefined` captures when called on a subclass instance.** A subclass | ||
| with no explicit constructor, extending a capture-bearing class | ||
| EXPRESSION bound to a local (`const Base = class { m() { return cap; } }; | ||
| const Sub = class extends Base { n() {...} };`), never forwarded `Base`'s | ||
| captured enclosing-scope locals to the synthesized subclass constructor — | ||
| the lowering deliberately drops the static `extends_name` for such a | ||
| lexically-local heritage identifier (avoiding a same-named-class | ||
| collision, #5437), and capture propagation was keyed off that same name. | ||
| Capture forwarding now resolves the heritage identifier through the same | ||
| let/const class-alias table `new X()` construction already uses, scoped | ||
| to subclasses that have their own member and no explicit constructor of | ||
| their own (an explicit constructor already forwarded captures correctly | ||
| through a separate mechanism). This was blocking `typescript`'s CJS | ||
| `transpileModule` output (`IdentifierNameMultiMap extends | ||
| IdentifierNameMap`, both class expressions with their own methods, in the | ||
| bundled `typescript.js`). A subclass with a completely empty body, or | ||
| whose base is a class DECLARATION rather than expression, hits a | ||
| separate, still-open codegen field-layout gap (#10486). | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
crates/perry-hir/src/lower/tests/class_expr_subclass_captures.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| //! #10486: a class extending a capture-bearing class EXPRESSION held in a | ||
| //! local (`const Base = class { m() { return cap; } }; class Sub extends | ||
| //! Base {}`) must forward the base's captured locals to the synthesized | ||
| //! subclass constructor, even though `extends_name` is deliberately left | ||
| //! `None` for this lexically-local heritage shape (see the #5437 PQueue | ||
| //! fix). Split from `tests.rs` for the 2000-line file cap. | ||
|
|
||
| /// The minimal repro from #10486: a subclass EXPRESSION with no own | ||
| /// constructor extending a base class EXPRESSION, both capturing distinct | ||
| /// enclosing-function locals. The `new Sub()` construction site must | ||
| /// forward BOTH captured ids (the base's and the subclass's own), not just | ||
| /// the subclass's own capture. | ||
| #[test] | ||
| fn subclass_of_local_class_expr_forwards_base_captures() { | ||
| let source = r#" | ||
| function outer() { | ||
| const baseCap = "base-capture"; | ||
| const subCap = "sub-capture"; | ||
| const Base = class { m() { return baseCap; } }; | ||
| const Sub = class extends Base { n() { return subCap; } }; | ||
| return new Sub().m(); | ||
| } | ||
| "#; | ||
| let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); | ||
| let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); | ||
| let outer = hir | ||
| .functions | ||
| .iter() | ||
| .find(|f| f.name == "outer") | ||
| .expect("fixture declares function outer"); | ||
| let compact: String = format!("{:?}", outer.body) | ||
| .chars() | ||
| .filter(|ch| !ch.is_whitespace()) | ||
| .collect(); | ||
| // The `new Sub()` site must append TWO forwarded capture args (the | ||
| // subclass's own `subCap` plus the inherited `baseCap`), not one. | ||
| assert!( | ||
| compact.contains("cap_args_appended:2"), | ||
| "expected new Sub() to forward both the base and subclass captures \ | ||
| (cap_args_appended: 2); got body: {compact}" | ||
| ); | ||
| } | ||
|
|
||
| // NOTE: a subclass DECLARATION (as opposed to expression) extending a local | ||
| // class expression is a separate lowering path (`Expr::NewDynamic` with a | ||
| // `RegisterClassCaptures`/`RefreshClassExprCaptures`-based shared-box | ||
| // capture mechanism, not `Expr::New{cap_args_appended}`) that this fix does | ||
| // not cover — left as a known gap, see the PR body for #10486. | ||
|
|
||
| /// A subclass that captures nothing of its own, extending a capture-bearing | ||
| /// local class expression, must still forward the base's capture (a | ||
| /// regression the naive "skip if the child's own union is empty" shape | ||
| /// would have reintroduced). | ||
| #[test] | ||
| fn subclass_with_no_own_captures_still_forwards_base_captures() { | ||
| let source = r#" | ||
| function outer() { | ||
| const cap = "only-base-cap"; | ||
| const Base = class { m() { return cap; } }; | ||
| const Sub = class extends Base {}; | ||
| return new Sub().m(); | ||
| } | ||
| "#; | ||
| let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); | ||
| let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); | ||
| let outer = hir | ||
| .functions | ||
| .iter() | ||
| .find(|f| f.name == "outer") | ||
| .expect("fixture declares function outer"); | ||
| let compact: String = format!("{:?}", outer.body) | ||
| .chars() | ||
| .filter(|ch| !ch.is_whitespace()) | ||
| .collect(); | ||
| assert!( | ||
| compact.contains("cap_args_appended:1"), | ||
| "expected new Sub() to forward the base's capture even though Sub \ | ||
| itself captures nothing; got body: {compact}" | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| // #10486: an inherited method of a capture-bearing class EXPRESSION saw | ||
| // `undefined` captures when called on an instance of a capture-bearing | ||
| // `class extends` subclass with no explicit constructor. | ||
| // | ||
| // `lower_class_decl`/`lower_class_from_ast` (crates/perry-hir/src/lower_decl/ | ||
| // class_decl.rs) deliberately leave `extends_name` at `None` when the | ||
| // heritage identifier resolves to a lexically-scoped local (`locally_ | ||
| // shadowed`) rather than a statically-registered class declaration — the | ||
| // #5437 PQueue fix, avoiding a retained name being re-resolved by the | ||
| // static parent-chain walks to an unrelated same-named class. | ||
| // `synthesize_class_captures` (lower_decl/class_captures.rs) unions a | ||
| // parent's registered captures into the child's synthesized-constructor | ||
| // params keyed off that SAME `extends_name`; when it's `None`, the union | ||
| // never ran, so the subclass constructor never received the base's | ||
| // captured locals and every inherited method read `undefined` for them. | ||
| // | ||
| // This is exactly the shape `const Base = class {...}; const Sub = class | ||
| // extends Base {...}` produces (both class EXPRESSIONS assigned to | ||
| // locals) — esbuild/tsc's typical bundled-class emit, and what broke | ||
| // typescript 5.8.2's CJS `transpileModule` (`IdentifierNameMultiMap | ||
| // extends IdentifierNameMap`, both class expressions in `typescript.js`'s | ||
| // module wrapper, the subclass with its own `add`/`remove` methods). | ||
| // | ||
| // NOTE: this fix is scoped to a subclass that has at least one member of | ||
| // its own (a method, in this file) and NO explicit constructor of its own | ||
| // — see `explicitCtor` below, the pre-existing "already works" control | ||
| // this fix must not regress. A subclass with a completely empty body | ||
| // (`const Sub = class extends Base {};`), or a base class DECLARATION | ||
| // (rather than expression) as the extends target, hits a SEPARATE, unfixed | ||
| // codegen field-layout bug — see the PR body for #10486. | ||
| // | ||
| // Each function below uses its own distinct Base/Sub identifier names | ||
| // (Base1/Sub1, Base2/Sub2, ...): re-using the same literal name across | ||
| // sibling functions hits an unrelated, pre-existing collision in the | ||
| // class-capture registries (confirmed present on a build with NONE of this | ||
| // PR's changes) that is out of this PR's scope. | ||
|
|
||
| function minimal(): void { | ||
| const baseCap = "base-capture"; | ||
| const subCap = "sub-capture"; | ||
| const Base1 = class { | ||
| m() { | ||
| return baseCap; | ||
| } | ||
| }; | ||
| const Sub1 = class extends Base1 { | ||
| n() { | ||
| return subCap; | ||
| } | ||
| }; | ||
| console.log("minimal", new Base1().m(), new Sub1().m(), new Sub1().n()); | ||
| } | ||
| minimal(); | ||
|
|
||
| // Control: an explicit `constructor() { super(); }` on the subclass must | ||
| // keep working (pre-existing "Works" case; a naive union of parent | ||
| // captures into every subclass regressed exactly this shape during | ||
| // development of this fix — kept here as the regression guard). | ||
| function explicitCtor(): void { | ||
| const cap2 = "explicit-super-cap"; | ||
| const Base2 = class { | ||
| m() { | ||
| return cap2; | ||
| } | ||
| }; | ||
| const Sub2 = class extends Base2 { | ||
| constructor() { | ||
| super(); | ||
| } | ||
| }; | ||
| console.log("explicitCtor", new Sub2().m()); | ||
| } | ||
| explicitCtor(); | ||
|
|
||
| // A captured HELPER function (not just a string) read from an inherited | ||
| // method on a subclass instance; subclass has its own (uncaptured) member. | ||
| function capturedHelper(): void { | ||
| function helper3(x: string): string { | ||
| return "[" + x + "]"; | ||
| } | ||
| const Base3 = class { | ||
| m() { | ||
| return helper3("base"); | ||
| } | ||
| }; | ||
| const Sub3 = class extends Base3 { | ||
| own() { | ||
| return "own"; | ||
| } | ||
| }; | ||
| console.log("capturedHelper", new Sub3().m(), new Sub3().own()); | ||
| } | ||
| capturedHelper(); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Summarize the shipped behavior.
This fragment includes lowering-table details and multiple development-scope caveats. Reduce it to one release-facing statement of the fixed behavior. This prevents assembled release notes from describing development slices instead of the shipped result.
Based on learnings: changelog fragments must describe the final shipped behavior as one coherent release-note entry.
🧰 Tools
🪛 LanguageTool
[style] ~19-~19: ‘completely empty’ might be wordy. Consider a shorter alternative.
Context: ...led
typescript.js). A subclass with a completely empty body, or whose base is a class DECLAR...(EN_WORDINESS_PREMIUM_COMPLETELY_EMPTY)
🤖 Prompt for AI Agents
Source: Learnings