Skip to content

module-level const/let reads before the declaration don't throw (no TDZ at module level) #11826

Description

@proggeramlug

In a compound member assignment whose base is a const binding still in its temporal dead zone, perry evaluates the computed key first and then throws a TypeError. Per spec, the base is evaluated first, so reading the binding throws a ReferenceError before the key expression runs.

Repro (main 34acf64e, v0.5.1656, Linux x86_64):

let calls = 0;
function f(): string { calls++; return "a"; }
try {
  tdz[f()] += 1;
  console.log("no throw");
} catch (e: any) {
  console.log(e.constructor.name, "f calls:", calls);
}
const tdz: any = { a: 1 };
runtime output
node ReferenceError f calls: 0
bun ReferenceError f calls: 0
perry TypeError f calls: 1

Two differences: the key's side effect (f()) runs when it shouldn't, and the error type is wrong. The TDZ check on the base is either skipped or ordered after the key spill in the compound-assignment lowering (hoist_compound_member_assign, crates/perry-hir/src/lower/expr_assign.rs).

Found while building #11825, which doesn't change this behaviour: that PR keeps the base temp, ahead of the key, whenever the key is spilled.

Activity

  1. proggeramlug commented on Oct 4, 2026

    @proggeramlug
    ContributorAuthor

    Correction to the diagnosis above, from a closer look on main 0a5bb47dc. The compound-assignment lowering is not at fault: it spills the base before the key. The real gap is that module-level const/let bindings have no TDZ at all. A read before the declaration yields undefined instead of throwing, so the compound form is only one symptom.

    At module level (node throws ReferenceError in every case):

    • const y = tdz; before the declaration: no throw.
    • tdz.a, tdz["a"] += 1, tdz.a += 1, tdz[f()], tdz[f()] = 1, tdz[f()] += 1: TypeError, and the forms with a key call run f.
    • () => tdz called before the declaration: returns undefined.

    Inside a function body every one of these forms already throws ReferenceError with zero key calls, so function-level TDZ works.

    A fix needs TDZ for module-level lexical bindings. That means either a static rule (a read lexically before the declarator, at the same function level, throws) plus a check for closures that can run early, or a TDZ cell per module-level binding, which puts a cost on every global read. A prior attempt, #9762, was reverted in 1d63fa91f. Related: #6062, #6264, #6523, #11086. Retitling to match.

  2. changed the title [-]tdz[f()] += 1 on a const in its TDZ runs the key and throws TypeError instead of ReferenceError[/-] [+]module-level const/let reads before the declaration don't throw (no TDZ at module level)[/+] on Oct 4, 2026
  3. proggeramlug commented on Oct 4, 2026

    @proggeramlug
    ContributorAuthor

    Reopening: the cyclic-export TDZ follow-up (#11963) was reverted in #11989 because it broke tsc and qs (#11987). The cycle case still needs a correct fix.

  4. proggeramlug commented on Oct 5, 2026

    @proggeramlug
    ContributorAuthor

    Fixed: module-level TDZ landed in 4d740d2, and the import-cycle case re-landed in #11995 (with a CommonJS skip), verified against node on tsc, Zod, qs, commander and hello.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions