Skip to content

??= on a static #private field drops the RHS value #22338

@jon301

Description

@jon301

Describe the bug

When @vitejs/plugin-legacy is enabled under Vite 8, a static get accessor that lazy-inits a static private field with this.#priv ??= new T() is bundled with the constructor invocation replaced by null. The singleton is therefore stuck at null forever, and any access throws TypeError: Cannot read properties of null on the first call.

The bug affects the modern bundle, not just the legacy one. Without @vitejs/plugin-legacy, Vite 8 compiles the same source correctly (keeping the native this.#priv ??= new T() form); enabling @vitejs/plugin-legacy forces the static private field to be lowered to a helper-based form, and the lowering miscompiles the ??= RHS.

Reproduction

Repro repo: https://github.com/jon301/vite8-plugin-legacy-static-private-nullish-bug

git clone https://github.com/jon301/vite8-plugin-legacy-static-private-nullish-bug
cd vite8-plugin-legacy-static-private-nullish-bug
pnpm install
pnpm build
pnpm preview

Open the served page; you'll see TypeError: Cannot read properties of null (reading 'hello') instead of hello from singleton.

System Info

  • vite: 8.0.8 and 8.0.10 (both reproduce)
  • @vitejs/plugin-legacy: 8.0.1
  • node: 24.13.0
  • platform: macOS

No React or other framework plugin is required to reproduce; only @vitejs/plugin-legacy.

Source

class Singleton {
  static #shared?: Singleton;

  static get shared(): Singleton {
    this.#shared ??= new Singleton();
    return this.#shared;
  }

  hello(): string {
    return "hello from singleton";
  }
}

console.log(Singleton.shared.hello());

Expected output (and what happens without @vitejs/plugin-legacy)

static get shared(){return this.#e??=new e,this.#e}

…and the page prints hello from singleton.

Actual output (modern bundle, with @vitejs/plugin-legacy)

static get shared() {
  return t(e, this, r)._ ?? (r._ = t(e, this, null)), t(e, this, r)._;
}

The third arg to the helper should hold the assigned value (i.e. new e()), but the build emits null. So r._ is set to null and stays null on every subsequent call.

After applying the workaround below, the same line is correctly emitted as:

static get shared() {
  return t(e, this, r)._ || (r._ = t(e, this, new e())), t(e, this, r)._;
}

Workaround

Replace ??= with an explicit if:

static get shared(): Singleton {
  if (!this.#shared) {
    this.#shared = new Singleton();
  }
  return this.#shared;
}

The modern bundle then correctly preserves the new T() invocation.

What does NOT trigger it

  • Vite 8 without @vitejs/plugin-legacy — the modern bundle keeps the native ??= and new T() forms intact.
  • ??= on regular (non-static, non-private) fields like a React ref.current — works fine.

Validations

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug: upstreamBug in a dependency of Vitep3-minor-bugAn edge case that only affects very specific usage (priority)

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions