[4.0.0] Phaser.Class (incl. Class.mixin) is not exposed on the public ESM namespace — forces third-party plugins into inadequate mixin fallbacks
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 40.3k
- Forks
- 7.2k
- PR merge metrics
- No merged PRs in 30d
Description
[4.0.0] Phaser.Class (incl. Class.mixin) is not exposed on the public ESM namespace — forces third-party plugins into inadequate mixin fallbacks
Versions
- Phaser:
4.0.0(latest stable) - Build: ESM via Vite
7.3.2(node_modules/phaser/dist/phaser.esm.js) - Browser: Chrome (any recent), renderer-agnostic (this is a JS-namespace ergonomics issue, not a render issue)
- OS: Windows
- Third-party plugin hitting the fallback:
@esotericsoftware/spine-phaser-v4@4.2.110
Summary
Phaser.Class is present inside the ESM bundle (the internal Class utility including Class.mixin, Class.extend, and the getProperty helper that unwraps { value: { get, set } } accessor descriptors) but is not re-exported on the public Phaser object that consumers receive via import * as Phaser from "phaser".
Third-party plugins that feature-detect Phaser.Class?.mixin (including the official @esotericsoftware/spine-phaser-v4) therefore always take their fallback branch on ESM builds. Those fallbacks typically don't replicate Class.mixin's def.value-unwrap semantics, which causes Phaser 4 component mixins (Transform, Origin, Alpha, Flip, ScrollFactor, Visible, Depth, ComputedSize, ...) to be installed as broken data descriptors instead of real accessors on the consuming class's prototype.
The consequence for my project: SpineGameObject.rotation reads as a literal { get, set } object, GetCalcMatrix produces NaN, and all Spine skeletons render invisible.
This request is a small API ergonomics fix on Phaser's side. The actual rendering bug is more accurately a bug in spine-phaser-v4's fallback (filed at EsotericSoftware/spine-runtimes#3065. But re-exporting Phaser.Class here would make that fallback path unreachable and pre-empt the same class of bug in every other plugin that does the same feature-detect.
Expected behavior
import * as Phaser from "phaser";
typeof Phaser.Class; // 'function'
typeof Phaser.Class.mixin; // 'function'
typeof Phaser.Class.extend; // 'function'
— identical to Phaser 3.x, where consumers and plugins have relied on Phaser.Class.mixin since at least 3.0 to correctly install Phaser's component accessors on their own classes.
Actual behavior
import * as Phaser from "phaser";
typeof Phaser.Class; // 'undefined'
Plugins feature-detecting Phaser.Class?.mixin fall back to hand-rolled mixin code that doesn't implement Phaser's descriptor-unwrap contract, and their users get invisible / broken Game Objects.
Minimal repro
// repro.ts
import * as Phaser from "phaser";
// fail
console.log("Phaser.Class:", Phaser.Class);
console.log("Phaser.Class.mixin:", (Phaser as any).Class?.mixin);
// Confirm Class exists inside the bundle but isn't re-exported:
// rg -n "Class\\.mixin|var Class|Class = " node_modules/phaser/dist/phaser.esm.js
Result on Phaser 4.0.0:
Phaser.Class: undefined
Phaser.Class.mixin: undefined
(Class + Class.mixin are present in phaser.esm.js; just not listed in the final export manifest.)
Why this matters (concrete downstream break)
@esotericsoftware/spine-phaser-v4@4.2.110 contains this code in dist/mixins.js:
// ~L53
function applyMixins(target, mixins) {
if (Phaser.Class?.mixin) {
Phaser.Class.mixin(target, mixins); // never taken on ESM
} else {
applyMixinsFallback(target, mixins); // taken on ESM — broken
}
}
// ~L40
function applyMixinsFallback(target, mixins) {
for (const mixin of mixins) {
const source = mixin.prototype || mixin;
for (const key of Object.getOwnPropertyNames(source)) {
if (key === "constructor") continue;
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (descriptor) {
Object.defineProperty(target.prototype, key, descriptor); // naive
}
}
}
}
The fallback doesn't honor the def.value unwrap step that Phaser's own getProperty performs:
// phaser.esm.js — getProperty (invoked by Class.mixin)
var def = Object.getOwnPropertyDescriptor(definition, k);
if (def.value && typeof def.value === 'object') {
def = def.value; // ← the step every fallback fails to replicate
}
if (def && hasGetterOrSetter(def)) { /* install as accessor */ }
So Phaser.GameObjects.Components.Transform.scale, which ships as:
scale: {
get: function () { return (this._scaleX + this._scaleY) / 2; },
set: function (value) { /* ... */ }
}
gets installed on SpineGameObject.prototype as a data property whose value is { get, set }. hero.scale returns that object, NaN propagates, skeleton invisible.
A minimal prototype inspection confirms it:
const desc = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(hero),
"rotation"
);
// Phaser 4 ESM: { value: { get: ƒ, set: ƒ }, writable: true, enumerable: true, configurable: true }
// Expected: { get: ƒ, set: ƒ, enumerable: true, configurable: true }
Suggested fix
Re-export Class from the ESM entry so import * as Phaser from "phaser" receives it:
// src/index.js (or wherever the ESM public-surface aggregator lives)
export { default as Class } from "./utils/Class.js";
or, if the existing Phaser-namespace builder is centralized, add Class alongside the other already-exposed utilities (Math, Utils, Structs, Scenes.Events, etc.).
That's the entire fix. Class already exists in the bundle; only the public re-export is missing.
Bisect
| Configuration | Phaser.Class.mixin accessible? |
|---|---|
| Phaser 3.90.0 ESM | ✅ |
| Phaser 4.0.0 ESM | ❌ (this issue) |
| Phaser 4.0.0 global/UMD builds | ✅ |
Cross-reference
I filed the matching issue against spine-phaser-v4 so they're aware their fallback needs to match Class.mixin's semantics regardless of what Phaser decides here EsotericSoftware/spine-runtimes#3065
Either fix alone solves my specific symptom. Both fixes together would make it robust for every plugin author who reaches for Phaser.Class.mixin on instinct.
Workaround (what I'm shipping today)
Consumer-side prototype-walking shim against SpineGameObject.prototype that reinstalls the wrapped descriptors as real accessors. Works, but obviously not a pattern that scales to every plugin that touches Phaser mixins:
import * as Phaser from "phaser";
import { SpineGameObject } from "@esotericsoftware/spine-phaser-v4";
const FIXED = Symbol.for("spine-phaser-v4.accessorsPatched");
const isWrappedAccessor = (d) =>
d && typeof d.value === "object" && d.value !== null && "writable" in d &&
(typeof d.value.get === "function" || typeof d.value.set === "function");
export function installSpinePhaserCompat() {
const proto = SpineGameObject?.prototype;
if (!proto || proto[FIXED]) return;
const stopAt = Phaser.GameObjects.GameObject.prototype;
let current = proto;
while (current && current !== stopAt && current !== Object.prototype) {
for (const key of Object.getOwnPropertyNames(current)) {
if (key === "constructor") continue;
const desc = Object.getOwnPropertyDescriptor(current, key);
if (!isWrappedAccessor(desc)) continue;
const { get, set } = desc.value;
Object.defineProperty(current, key, { configurable: true, enumerable: desc.enumerable, get, set });
}
current = Object.getPrototypeOf(current);
}
Object.defineProperty(proto, FIXED, { value: true });
}
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with src/index.js or the ESM public-surface aggregator and inspect utils/Class.js, then compare the source exports with the generated phaser.esm.js manifest. Run the minimal repro.ts import and confirm that Phaser.Class, Class.mixin, and Class.extend are exposed on the public namespace.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, vite
- Domain
- api, game-dev
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 62/100