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
Describe the bug
When
@vitejs/plugin-legacyis enabled under Vite 8, astatic getaccessor that lazy-inits a static private field withthis.#priv ??= new T()is bundled with the constructor invocation replaced bynull. The singleton is therefore stuck atnullforever, and any access throwsTypeError: Cannot read properties of nullon 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 nativethis.#priv ??= new T()form); enabling@vitejs/plugin-legacyforces 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 previewOpen the served page; you'll see
TypeError: Cannot read properties of null (reading 'hello')instead ofhello from singleton.System Info
8.0.8and8.0.10(both reproduce)8.0.124.13.0No React or other framework plugin is required to reproduce; only
@vitejs/plugin-legacy.Source
Expected output (and what happens without
@vitejs/plugin-legacy)…and the page prints
hello from singleton.Actual output (modern bundle, with
@vitejs/plugin-legacy)The third arg to the helper should hold the assigned value (i.e.
new e()), but the build emitsnull. Sor._is set tonulland staysnullon every subsequent call.After applying the workaround below, the same line is correctly emitted as:
Workaround
Replace
??=with an explicitif:The modern bundle then correctly preserves the
new T()invocation.What does NOT trigger it
@vitejs/plugin-legacy— the modern bundle keeps the native??=andnew T()forms intact.??=on regular (non-static, non-private) fields like a Reactref.current— works fine.Validations
pnpm create viteminus the React parts).