Fix ESM script registration by name and subclass name collision#8831
Conversation
Script name resolution previously read `__name`/`scriptName` through the
prototype chain, which caused two bugs:
- ESM scripts declare their name via a static `scriptName` field. Being a
class field, it shadows the inherited `scriptName` accessor with [[Define]]
semantics, so the setter that assigns `__name` never runs. `ScriptRegistry.add`
read the unset `__name` and registered the script under a null key, so it
could not be created by name (e.g. `entity.script.create('test')`), breaking
templates/procedural/async workflows.
- A subclass inherited its base class's already-assigned `__name`/`scriptName`,
so registering the subclass overwrote the base in the registry (and could
trigger an unintended hot-swap).
Name resolution now considers only own properties of the class. Logic is
centralized in `getScriptRegistryName` (own `__name` -> own `scriptName` ->
lowerCamelCase class name) and used by `registerScript`, `ScriptRegistry.add`,
`ScriptComponent.create`, and the ESM script handler, so all paths resolve the
same name. Nameless scripts now consistently use the lowerCamelCase class name
(previously `registerScript`/`add` used PascalCase while the asset loader and
`create` already used camelCase).
Adds dedicated ScriptRegistry tests covering both fixes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes script name-resolution issues in the scripting framework by ensuring script names are resolved from own (non-inherited) class properties, preventing ESM scriptName shadowing bugs and subclass name-collision overwrites in the script registry.
Changes:
- Introduces
getScriptRegistryName()(own__name→ ownscriptName→ lowerCamelCase class name) and applies it across script registration and creation paths. - Updates
ScriptRegistry.add,registerScript,ScriptComponent.create, and the ESMScriptHandlerto use consistent, own-property-only name resolution. - Adds a new test suite covering ESM registration, subclass non-collision, and fallback naming behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
test/framework/script/script-registry.test.mjs |
Adds coverage for ESM scriptName registration, subclass behavior, and consistent fallback naming. |
src/framework/script/script.js |
Adds toLowerCamelCase export and getScriptRegistryName; tightens getScriptName to own scriptName only. |
src/framework/script/script-registry.js |
Uses centralized registry-name resolution in add() and handles missing-name failure. |
src/framework/script/script-create.js |
Switches registerScript name resolution to getScriptRegistryName. |
src/framework/handlers/script.js |
Updates ESM script loading to warn based on own scriptName and register via resolved registry name. |
src/framework/components/script/component.js |
Aligns ScriptComponent.create() naming/warnings with own-property-only resolution. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| script.__name = scriptName; | ||
|
|
||
| if (this._scripts.hasOwnProperty(scriptName)) { |
Address review feedback on edge cases where a script name cannot be resolved (e.g. an anonymous class with an empty `.name` and no static `scriptName`): - `toLowerCamelCase` returns the input unchanged for empty/falsy values instead of throwing on `str[0]`. - `registerScript` logs an error and returns early instead of registering under an undefined name and pushing to `ScriptTypes`. - `ScriptComponent.create` returns null (and logs) rather than indexing the instance under an `undefined`/empty key. - the ESM script handler skips exports whose name cannot be resolved. Adds tests for the registerScript and ScriptComponent.create fail-fast paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the review @copilot. Addressed the name-resolution edge cases (anonymous class with an empty Addressed (threads resolved):
Added tests for the Not addressed (left open):
|
|
@mvaligursky I've opened a new pull request, #8832, to work on those changes. Once the pull request is ready, I'll request review from you. |
| * @param {string} str - The string to convert. | ||
| * @returns {string} The converted string. | ||
| */ | ||
| export const toLowerCamelCase = str => (str ? str[0].toLowerCase() + str.substring(1) : str); |
| // a `scriptName` declared on THIS class; an inherited one would belong to a base class | ||
| const ownScriptName = Object.prototype.hasOwnProperty.call(scriptType, 'scriptName') && scriptType.scriptName; | ||
|
|
||
| if (!(scriptType.prototype instanceof ScriptType) && !ownScriptName) { | ||
| Debug.warnOnce(`The Script class "${inferredScriptName}" must have a static "scriptName" property: \`${inferredScriptName}.scriptName = "${toLowerCamelCase(inferredScriptName)}";\`. This will be an error in future versions of PlayCanvas.`); | ||
| } |
| // a `scriptName` declared on THIS class; an inherited one belongs to a base | ||
| const ownScriptName = Object.prototype.hasOwnProperty.call(scriptClass, 'scriptName') && scriptClass.scriptName; | ||
|
|
||
| if (!scriptClass.scriptName) { | ||
| Debug.warnOnce(`The Script class "${scriptClass.name}" must have a static "scriptName" property: \`${scriptClass.name}.scriptName = "${lowerCamelCaseName}";\`. This will be an error in future versions of PlayCanvas.`); | ||
| if (!ownScriptName) { | ||
| Debug.warnOnce(`The Script class "${scriptClass.name}" must have a static "scriptName" property: \`${scriptClass.name}.scriptName = "${toLowerCamelCase(scriptClass.name)}";\`. This will be an error in future versions of PlayCanvas.`); |
|
|
Summary
Fixes two long-standing script name-resolution bugs that share one root cause: names were read through the prototype chain instead of from the class itself.
#8824 — ESM script added to the registry not keyed by its name
ESM scripts declare their name with a static
scriptNamefield. Because it's a class field, it shadows the inheritedscriptNameaccessor ([[Define]]semantics), so the setter that assigns__namenever runs.ScriptRegistry.add()read the unset__nameand registered the script under anullkey — so it couldn't be created by name:This broke template instantiation, procedural creation, and async/dynamic script loading for engine-only projects.
#2881 — subclass of a script replaces the superclass
A subclass inherited its base class's already-assigned
__name/scriptName, so registering the subclass overwrote the base in the registry (and could trigger an unintended hot-swap):Fix
Name resolution now considers only own properties of the class. The logic is centralized in a new internal
getScriptRegistryName()helper — own__name→ ownscriptName→ lowerCamelCase class name — and used consistently byregisterScript,ScriptRegistry.add,ScriptComponent.create, and the ESM script handler.Compatibility
registerScript,createScript,ScriptRegistry/.add(),Scriptare unchanged; the new helpers are internal (not exported fromindex.js).scriptName, or a name passed tocreateScript/registerScript) are unaffected.registerScript/addnow resolve to the lowerCamelCase class name (matching the asset loader andcreate, which already did) instead of PascalCase.Tests
Adds
test/framework/script/script-registry.test.mjscovering both fixes (ESMaddby name, subclass non-collision, explicit-name precedence, nameless camelCase unification). Full suite passes.