Skip to content

Preserve optional chain short-circuiting through lowered private members in decorated classes#31914

Open
robobun wants to merge 3 commits into
mainfrom
farm/c0a9217a/fix-decorator-optional-chain
Open

Preserve optional chain short-circuiting through lowered private members in decorated classes#31914
robobun wants to merge 3 commits into
mainfrom
farm/c0a9217a/fix-decorator-optional-chain

Conversation

@robobun

@robobun robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes #31910. Optional chains that pass through a lowered private member access in a decorated class lose their short-circuiting: the ?. nullish check is dropped by the rewrite, so the __privateGet/__privateMethod helper is called with undefined and throws TypeError: Cannot read from private field instead of the chain evaluating to undefined.

function decorator() { return (v, ctx) => {}; }
class Foo {
  static #m = function () { return { Foo }; };
  @decorator() test(o) { return o?.Foo.#m(); }
}
new Foo().test(undefined); // expected: undefined; before this PR: TypeError

Cause

rewrite_private_accesses_in_expr in src/js_parser/lower/lower_decorators.rs rewrites EIndex/ECall nodes with private identifiers into helper calls without looking at the optional_chain flags, emitting

__privateGet(o?.Foo, _m).call(o?.Foo)

where o?.Foo evaluates to undefined without short-circuiting the enclosing call.

Fix

Before rewriting, detect optional-chain segments whose spine contains a lowered private access (or whose ?.() callee ends in one) and flatten the segment, hoisting the nullish test out, the way esbuild's decorator lowering does:

o == null ? void 0 : __privateGet(_a = o.Foo, _m).call(_a)
  • Receivers of private method calls, optional callees, and effectful chain bases are captured in temporaries so they evaluate exactly once. The temporaries are declared in a var statement emitted with the other lowering variables (WeakMaps, _dec, _init), covering both class statements and class expressions.
  • callee?.() segments preserve the this binding of the callee's member access via .call(recv), including across nested segments (o?.a.#m?.() calls #m with this === o.a; esbuild drops this here, Node keeps it, this PR matches Node).
  • Segments without a lowered private are left as native ?. syntax.
  • delete of a chain containing a lowered private pushes the delete into the non-null branch (o == null ? true : delete __privateGet(o, _f).x) so the property reference survives.
  • super.m?.() / super[k]?.() callees stay intact (bare super cannot be captured in a temporary) and call with the current this.
  • Each ?. in a multi-segment chain keeps its own test; inner segments are lowered recursively only when needed.

The lowered output matches esbuild's decorator lowering shape for the common forms (o?.#f, o?.a.#f, o?.a.#m(), o.#m?.(), g()?.a.#f, o?.a?.b.#f, ...).

Verification

New tests in test/bundler/transpiler/es-decorators.test.ts ("optional chains through lowered private members"): nullish short-circuit for field gets / method calls / direct accesses, non-null paths with exact values and this, optional calls of private members, multi-segment chains, evaluate-once + laziness of bases and computed keys, ?.() at segment start with a private above it, decorated class expressions, and private instance methods/getters.

bun bd test test/bundler/transpiler/es-decorators.test.ts: 43 pass with the fix; 7 of the 8 new tests fail with USE_SYSTEM_BUN=1 (the remaining one guards non-null path values, which already worked). Also ran es-decorators-esbuild.test.ts (147), decorators.test.ts (22), decorator-metadata.test.ts (5), bundler_decorator_metadata.test.ts (2), and transpiler.test.js (188): no regressions.

A 37-case differential fixture (every chain form above, nullish at each position plus hit paths, with side-effect counters) produces byte-identical output between Node (undecorated, native semantics) and this branch's lowering; 18 of the 37 cases throw TypeError on the unfixed build.

Rebased on main after #31426 (evaluate private method call receivers once) merged; it touches the same function. Resolution: kept main's temp-declaration machinery (drain_capture_temp_decls and the per-function-body declarations, which also make the temps reentrancy-safe) and dropped this PR's duplicate class-level drain block, and the receiver capture in the non-chain ECall arm now goes through this PR's capture_expr_for_reuse helper instead of an identical inline match. Both PRs' test suites pass together (54 tests in es-decorators.test.ts).

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 5 minutes and 44 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f24f9fc5-48eb-445b-af79-977027e58679

📥 Commits

Reviewing files that changed from the base of the PR and between 73ab3dd and 1d0b18d.

📒 Files selected for processing (2)
  • src/js_parser/lower/lower_decorators.rs
  • test/bundler/transpiler/es-decorators.test.ts

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

@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Jun 6th, 2026

@robobun, your commit 1d0b18d has 2 failures in Build #61035 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31914

That installs a local version of the PR into your bun-31914 executable, so you can run:

bun-31914 --bun

Comment thread src/js_parser/lower/lower_decorators.rs
@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI note: the failing lane is test/cli/install/bunx.test.ts > "should handle package that requires node 24", which runs bunx --bun @angular/cli@latest --help against the live registry. @angular/cli 22.0.0 raised its minimum to ^22.22.3 || ^24.15.0 || >=26.0.0, which bun's reported Node version (24.3.0) does not satisfy, so the CLI exits 3 on every run. It fails identically on 1.4.0-canary.1 without this PR's changes:

Node.js version v24.3.0 detected.
The Angular CLI requires a minimum Node.js version of v22.22.3 or v24.15.0 or v26.0.0.

#31820 pins the test to @angular/cli@21.1.3 and will clear this for the whole fleet. The decorator lowering change here is unrelated to that lane; all decorator and transpiler suites pass locally (es-decorators 44, es-decorators-esbuild 147, decorators 22, decorator-metadata 5, transpiler 188).

Comment thread src/js_parser/lower/lower_decorators.rs
Comment thread src/js_parser/lower/lower_decorators.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All three issues I flagged earlier are addressed and covered by tests, and this pass found nothing new — but ~400 lines of new optional-chain flattening in the decorator lowerer (this-binding, evaluate-once, super, delete) is subtle enough that a human should give it a once-over before merge.

Extended reasoning...

Overview

This PR adds optional-chain flattening to rewrite_private_accesses_in_expr in src/js_parser/lower/lower_decorators.rs so that ?. short-circuiting survives when a private member access in a decorated class is rewritten into __privateGet/__privateMethod helper calls. It introduces ~400 lines of new AST transformation: segment detection (optional_chain_needs_private_lowering), a flattener (lower_optional_chain_with_private) that rebuilds links bottom-up over a hoisted == null ? void 0 : ... test, callee handling for ?.() (rewrite_optional_callee) with receiver capture, a .call(recv) retargeter, temp-ref capture/declaration plumbing, and special cases for super callees and delete. Eleven new runtime tests in es-decorators.test.ts cover nullish/non-nullish paths, this preservation, evaluate-once, multi-segment chains, class expressions, instance methods/getters, super, the deeper-private-in-callee shape, and delete.

Prior review rounds

I raised three issues on earlier revisions, all confirmed and fixed with regression tests: (1) super.m?.().#priv emitting (_chain = super) — a SyntaxError; (2) o?.#f.b?.() losing the this binding when the lowered private is deeper in the callee chain; (3) delete o?.#f.x becoming a silent no-op because delete of a ternary value isn't a Reference. The current diff handles each of these in the way suggested, and the new tests assert the corrected behavior. This run's bug hunt found nothing further.

Security risks

None. This is a pure JS-to-JS lowering correctness fix in the transpiler; no auth, I/O, permissions, or untrusted-input parsing surface is touched.

Level of scrutiny

High. This is correctness-critical transpiler output: a mistake here silently changes the runtime semantics of user code (wrong this, double-evaluation, dropped delete, lost short-circuit) rather than failing loudly. The transformation interacts with several spec subtleties — Reference vs Value semantics, MakeSuperPropertyReference, OptionalChain evaluation order, strict-mode delete — and the fact that three real edge-case regressions surfaced across two review rounds confirms the surface is subtle. The test coverage is solid (11 targeted runtime tests plus a 37-case Node differential per the description), but the combinatorics of chain shapes are large enough that a maintainer familiar with lower_decorators.rs and the esbuild reference implementation should sanity-check the overall approach.

Other factors

The one CI failure (bunx.test.ts / Angular CLI Node-version gate) is unrelated and tracked separately. No CODEOWNERS entry covers this path. All prior inline threads are resolved.

robobun added 3 commits June 6, 2026 03:37
When standard decorator lowering rewrites private member accesses into
__privateGet/__privateMethod helper calls, the ?. tests on EDot/EIndex/ECall
nodes in the chain were discarded, so a nullish base reached the helper and
threw instead of short-circuiting to undefined.

Hoist the chain's nullish tests out before rewriting:
o?.a.#m() now lowers to
o == null ? void 0 : __privateGet(_a = o.a, _m).call(_a),
with receivers, callees, and effectful bases captured in temporaries that
are declared alongside the other lowering variables.

Fixes #31910
Bare super cannot be captured in a temporary; super.m?.() now tests the
callee value and calls it with the current this.
An optional call whose callee chain contains a lowered private deeper in
(o?.#f.b?.()) now routes through the callee rewrite so the receiver is
captured for .call() instead of leaving a native ?.() on a ternary value.

delete of a chain containing a lowered private pushes the delete into the
non-null branch (o == null ? true : delete __privateGet(o, _f).x) so the
property reference survives; delete of a ternary value is a silent no-op.
@robobun robobun force-pushed the farm/c0a9217a/fix-decorator-optional-chain branch from 1bc7036 to 1d0b18d Compare June 6, 2026 03:43

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All three issues I flagged earlier are addressed and covered by tests, and I didn't find anything further — but ~380 lines of new optional-chain flattening logic in the decorator lowering pass is intricate enough that a human should sign off.

Extended reasoning...

Overview

This PR adds roughly 380 lines of new Rust to src/js_parser/lower/lower_decorators.rs, introducing five new helper functions (capture_expr_for_reuse, lowered_private_index_info, optional_chain_needs_private_lowering, retarget_call_through_dot_call, rewrite_optional_callee, lower_optional_chain_with_private) and a pre-pass in rewrite_private_accesses_in_expr that detects optional-chain segments containing lowered private accesses and flattens them into base == null ? void 0 : ... form so the ?. short-circuit survives the __privateGet/__privateMethod rewrite. It also adds ~220 lines of runtime tests in es-decorators.test.ts (11 new test cases) plus a small refactor folding the existing receiver-capture match into the new capture_expr_for_reuse helper.

Security risks

None. This is a transpiler AST transformation; it doesn't touch auth, crypto, network, filesystem, or permission boundaries. The only blast radius is correctness of emitted JavaScript for decorated classes.

Level of scrutiny

High. This is core transpiler output-correctness code with many interacting edge cases: this-binding preservation across .call() rewrites, super property callees (which can't be captured in a temp), delete of a flattened chain (must keep a Reference on the non-null branch), multi-segment chains where each ?. keeps its own test, optional calls whose callee is itself a chain with a private deeper in, evaluate-once semantics for effectful bases and computed keys, and temp-variable declaration placement (per-function-body vs. class-level). During review I found three separate regressions in earlier revisions (_chain = super SyntaxError, lost this for o?.#f.b?.(), and delete becoming a no-op), all of which the author confirmed and fixed with added tests — but the fact that three real bugs surfaced in successive passes is itself a signal that this transformation is subtle.

Other factors

  • All three of my prior inline comments are resolved, the fixes match the suggested shapes, and each has a dedicated runtime test in the new suite.
  • The bug-hunting system found nothing on the current revision.
  • Test coverage is good: 11 new runtime tests exercise nullish/non-nullish paths, this capture, evaluate-once, multi-segment chains, super, delete, class expressions, and instance methods/getters; the PR description also references a 37-case Node differential fixture.
  • The one CI failure (bunx.test.ts Angular CLI Node-version check) is unrelated and tracked separately.
  • The change is not mechanical — it's a hand-written lowering algorithm with mutual recursion between lower_optional_chain_with_private, rewrite_optional_callee, and rewrite_private_accesses_in_expr. Given the size and the demonstrated edge-case density, I'd rather a human familiar with the parser/lowering code give it a final look than auto-approve.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optional chains through lowered private members do not short-circuit in decorated classes

1 participant