Skip to content

class methods referencing a const declared after the class: capture omitted (ReferenceError) or spurious TDZ at class definition — kills Next.js standalone boot (bundled semver) #6523

Closed

Description

@proggeramlug

Summary

A class whose methods reference a lexical binding (const/let) declared after the class miscompiles. Legal JS — TDZ applies at call time, and the bindings are initialized before any method runs — but perry's class-capture machinery snapshots captures by value at the class's declaration site, so forward bindings break in two ways:

A. Simple function scope — capture omitted entirely:

(function factory(e) {
  const s = Symbol("x");
  class Comparator {
    static get ANY() { return s; }
    constructor(v) { a("dbg", v); this.max = n; }   // a, n declared BELOW
  }
  e.exports = Comparator;
  const a = function () {};
  const n = 16;
  new Comparator("x");   // perry: ReferenceError: a is not defined   node: works
})({ exports: {} });

HIR shows ClassExprFresh { captured_args: [LocalGet(s)] } — a/n are missing from the captures.

B. Module-factory scope with TDZ boxes — spurious TDZ throw at class definition:

next/dist/compiled/semver module 842 (const s = Symbol("SemVer ANY"); class Comparator {...} with the debug fn + sibling requires declared below the class) lowers to PreallocateTdzBoxes([41..46]) + ClassExprFresh { captured_args: [LocalGet(41)..LocalGet(46), LocalGet(47)] }. The captured-arg LocalGet on a TDZ box emits a checked value read (js_box_get_bits → js_throw_reference_error_tdz), so defining the class throws ReferenceError: Cannot access undefined before initialization — at module init, before any user code runs.

Impact

Any bundled/minified package that declares module constants after a class that uses them. Concretely: next/dist/compiled/semver — a Next.js 16 standalone server dies at BOOT (before Ready, 100% deterministic) once module 608/842's init runs. This is the current gscmaster boot blocker (behind the statuses .message bug fixed in #6522).

lldb stack (debug-symbol build, b js_throw_reference_error_tdz):

js_throw_reference_error_tdz ← js_box_get_bits ← …semver_index_js__13 (module 842 factory)
  ← js_native_call_value ← …__167 (__nccwpck_require__) ← …__12 ← …__0 ← init_body ← main

Contrast

Plain closures/arrows capturing the same forward-declared consts work correctly (box-based captures, read at call time). Only the class path (RegisterClassCaptures / ClassExprFresh decl-site snapshot; class_captures.rs "decl-site snapshot" for statics) has this semantics gap. The shared-mutable-capture path (#6497/#6470) already keeps whole box handles for mutated captured locals — but forward-declared immutable bindings either aren't collected (A) or are TDZ boxes read eagerly (B).

Direction

Class captures of not-yet-initialized lexical bindings need the closure treatment: capture the box, read through it at method-call time (mirror __perry_cap_* field reads / static snapshot reads via id[0]), never a checked value read at class-definition time. Case A additionally needs the capture collection to include bindings declared after the class (forward lexical names are already tracked for the TDZ machinery — forward_lexical_names, #6062).

Repros: standalone files above; the full trigger is perry compile of next/dist/compiled/semver/index.js + require driver (dies at init).

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