Skip to content

Keep the options an awaiting script was declared with - #9201

Merged
mvaligursky merged 1 commit into
mainfrom
mv-script-awaiting-enabled
Aug 21, 2026
Merged

Keep the options an awaiting script was declared with#9201
mvaligursky merged 1 commit into
mainfrom
mv-script-awaiting-enabled

Conversation

@mvaligursky

@mvaligursky mvaligursky commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Description

Stacked on #9200 — base is mv-script-clone-awaiting, review the second commit only.

When a script type is registered after the components referencing it were created, the deferred sweep in ScriptRegistry#add creates the script instances. It read the declared attributes out of _scriptsData but never the declared enabled flag, so ScriptComponent#create fell back to its true default. A script declared enabled: false came back enabled and got initialized — unlike the same script going through the eager initializeComponentData path.

Reproduces with no cloning involved:

e.addComponent('script', {
    order: ['loadedLater'],
    scripts: { loadedLater: { enabled: false, attributes: {} } }
});
// ... once loadedLater is registered:
e.script.loadedLater.enabled;   // true, and initialize() has run

_scriptsData is only populated for scripts that came from component data, so a script created through the public create() path fared worse still — the awaiting entry kept nothing but { awaiting, ind }, so enabled, attributes and the assigned properties were all dropped:

e.script.create('later', {
    enabled: false,
    attributes: { speed: 42 },
    properties: { customValue: 42 }
});
// ... once the type is registered: enabled, initialized, speed and customValue gone

Fix

The awaiting entry keeps all three, and is the only thing the deferred creation and cloneComponent read. So deferred creation ends up with the instance eager creation would have produced, on the entity and on a clone of it.

Reading _scriptsData was the underlying problem rather than an incomplete fix:

  • it is not per declaration — destroy() drops the index entry but leaves _scriptsData[name] behind, so after destroy('later') + create('later', { enabled: false }) the sweep would resurrect the old declaration's values
  • it is not per component — the attributes local was declared outside the component loop and only reassigned when the component had _scriptsData, so a component with no declared data for that script inherited whichever earlier component in the loop had some

Both fall out of using the awaiting entry, which is created by the declaration that is actually standing, on the component that made it. Nothing needs to clear _scriptsData, so no caller-supplied scripts object gets mutated.

The eager path loses nothing by this: initializeComponentData passes the _scriptsData values into create(), so the awaiting entry captures exactly what was declared.

Testing

Five tests in test/framework/components/script/component.test.mjs, all five fail without this commit:

  • a script declared enabled: false stays disabled and is not initialized when its type is registered later
  • a script created through create() before its type exists keeps its enabled flag, attributes and properties
  • a clone of such a script keeps them too
  • a destroyed-and-recreated awaiting script uses the new options, not the stale component data, on both the entity and a clone
  • attribute data does not leak from one component to the next in the sweep

Note

properties is deliberately not threaded through cloning. It has no representation in serialized component data, and cloneComponent does not carry assigned properties for scripts that already exist either — doing it for awaiting scripts only would make them clone richer than ordinary ones. The clone carries enabled and attributes, which do have a component data representation.

Checklist

  • I have read the contributing guidelines
  • My code follows the project's coding standards
  • This PR focuses on a single change

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2372.0 KB (+0.0 KB, +0.00%) 609.5 KB (−0.0 KB, −0.00%) 473.5 KB (+0.2 KB, +0.04%)
playcanvas.min.mjs 2369.4 KB (+0.0 KB, +0.00%) 608.4 KB (−0.0 KB, −0.00%) 472.7 KB (−0.1 KB, −0.01%)

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review by Codex (GPT-5).

I found one remaining correctness gap in the same public awaiting-script path. The per-component local fixes the cross-component attribute leak, and serialized component data now carries enabled correctly, but options supplied through ScriptComponent#create() are still discarded before this sweep.

The submitted focused suite passed (90 tests), ESLint and git diff --check passed, and all current GitHub checks are green. A targeted programmatic-awaiting regression probe failed as described inline.

Comment thread src/framework/script/script-registry.js Outdated

// the data the script was declared with, if any - note this must be read per
// component, a component without it must not inherit the previous one's data
const scriptData = component._scriptsData?.[scriptName];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve options from programmatic awaiting creation. _scriptsData is populated by initializeComponentData, but the public component.create('later', { enabled: false, attributes: { speed: 42 } }) path stores only { awaiting, ind } when the type is absent. Consequently scriptData is undefined here, the instance is created enabled, initialize() runs, and its attributes are lost. A focused probe on this head reproduced _enabled === true instead of false. Please persist the original create() options in the awaiting entry (or shared component data) and consume them here as well.

@mvaligursky

Copy link
Copy Markdown
Contributor Author

Fixed in d11cb26 — good catch, that is the same bug through the public entry point. The awaiting entry now keeps what create() was called with (enabled and attributes), and the sweep falls back to it when the script did not come from component data. Test added for create('later', { enabled: false, attributes: { disableEntity: true } }): stays disabled, is not initialized, and keeps the attribute.

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated re-review by Codex (GPT-5).

Re-reviewed the changes since commit 71e78d4. The previous finding is resolved for enabled and attributes: programmatic awaiting entries retain those values, and the registry sweep consumes the per-component fallback correctly. The focused ScriptComponent suite passed (91 tests); changed-file ESLint, diff validation, and all current CI checks are green.

One remaining public-create parity gap was exposed by a targeted probe and is noted inline.

// ScriptRegistry#add has nothing else to go on for a script that was not declared
// through component data
enabled: args.hasOwnProperty('enabled') ? args.enabled : true,
attributes: args.attributes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve properties for programmatic awaiting creation

ScriptComponent#create publicly documents args.properties and applies it with Object.assign when the type is already registered, but this awaiting record keeps only enabled and attributes, and the later registry call likewise cannot forward properties. A targeted probe using create('loadedLater', { properties: { customValue: 42 } }) produced an instance whose customValue was undefined after registration. Please store and forward this public option as well so delayed creation has the same semantics as eager creation. When reconciling this PR with #9200/#9202, the same data also needs to survive cloning while retaining #9202's computed insertion index.

@mvaligursky
mvaligursky force-pushed the mv-script-awaiting-enabled branch from d11cb26 to 5dcc235 Compare August 21, 2026 09:12
@mvaligursky
mvaligursky changed the base branch from main to mv-script-clone-awaiting August 21, 2026 09:12
@mvaligursky mvaligursky changed the title Respect the declared enabled state of a script added to the registry later Keep the options an awaiting script was declared with Aug 21, 2026
@mvaligursky

Copy link
Copy Markdown
Contributor Author

Fixed in 5dcc235properties is now kept on the awaiting entry and forwarded by the sweep, and the test asserts customValue === 42 after registration alongside the enabled flag and attributes.

On the parenthetical about #9200/#9202: the PR is now stacked on #9200 (the whole chain is linear, 9200 → 9201 → 9202, so nothing conflicts). cloneComponent reads the awaiting entry as its fallback, which fixes a gap the two PRs had between them — a programmatically created awaiting script cloned with default enabled/attributes because the clone only looked at _scriptsData. Test added for that. #9202's computed insertion index is untouched by this.

properties itself is deliberately not carried through cloning: it has no serialized component data representation, and cloneComponent does not propagate assigned properties for already-created scripts either, so doing it for awaiting scripts only would make them clone richer than ordinary ones.

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated re-review by Codex (GPT-5).

Re-reviewed the latest commit stacked on #9200. The previous properties finding is resolved, including deferred creation on the original component, and the deliberate omission of assigned properties from clones matches existing ordinary-script clone semantics. The focused stacked ScriptComponent suite passed (97 tests); changed-file ESLint, diff validation, and all current CI checks are green.

One remaining destroy/recreate state bug was exposed by a targeted lifecycle probe and is noted inline.

Comment thread src/framework/script/script-registry.js Outdated
// the data the script was declared with - component data if it came from
// there, otherwise what create() was called with. Note this must be read per
// component, a component without it must not inherit the previous one's data
const scriptData = component._scriptsData?.[scriptName] ?? indexData;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not let stale component data override recreated options

ScriptComponent#destroy removes the index entry but does not remove _scriptsData[scriptName], so this presence check cannot tell where the current awaiting declaration came from. Reproduce by declaring missing loadedLater in component data with enabled: true, destroying it, then calling create('loadedLater', { enabled: false, attributes: { disableEntity: false } }): after registration this line selects the old component data, and a targeted probe observed the recreated instance enabled and initialized instead of disabled. cloneComponent has the same _scriptsData ?? indexData precedence, so a clone also retains the stale values. Please clear the raw entry when the declaration is destroyed or otherwise track the current declaration's source so both the sweep and clone use the newly supplied options.

@mvaligursky
mvaligursky force-pushed the mv-script-awaiting-enabled branch from 5dcc235 to 64fe7e0 Compare August 21, 2026 09:22
@mvaligursky

Copy link
Copy Markdown
Contributor Author

Fixed in 64fe7e0, and you were right that this needed the "track the current declaration's source" half rather than a targeted patch — so the _scriptsData read is gone from both the sweep and cloneComponent. The awaiting entry is now the only source: it is created by the declaration that is actually standing, on the component that made it, which makes both the stale-after-destroy case and the cross-component leak structurally impossible rather than separately guarded.

I did not clear _scriptsData[scriptName] in destroy(), which was the other option you offered. _scriptsData is assigned straight from the caller's scripts object in initializeComponentData, so deleting a key from it mutates data the caller may be reusing across entities — the same aliasing #9203 is about. With nothing reading it for awaiting scripts there is no reason to touch it.

Nothing is lost from the eager path: initializeComponentData passes the _scriptsData values into create(), so the awaiting entry captures exactly what was declared. And the scripts setter cannot refresh an awaiting script's data today either way — it does script.once(...) / script.__attributes.hasOwnProperty(...) on the index entry, which for an awaiting entry is a plain object, so it throws.

Test added for the destroy/recreate case asserting both the entity and a clone of it.

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated re-review by Codex (GPT-5).

No actionable issues found in the latest update.

The previous stale-data finding is resolved at the correct ownership boundary: the current awaiting index entry is now the sole source for deferred creation and clone reconstruction, so destroy/recreate cannot resurrect an earlier declaration and no caller-owned _scriptsData object needs to be mutated. The new regression test covers both the source and clone paths.

Local verification on the exact head: the stacked ScriptComponent suite passed (98 tests), changed-file ESLint passed, and git diff --check passed. The two GitHub checks currently registered for this amended head are both green; the broader Actions checks were not yet listed at review time.

Base automatically changed from mv-script-clone-awaiting to main August 21, 2026 09:40
When a script type is registered after the components referencing it have been
created, the deferred sweep in ScriptRegistry#add creates the script instances.
It read the declared attributes out of `_scriptsData` but never the declared
`enabled` flag, so `ScriptComponent#create` fell back to its `true` default: a
script declared `enabled: false` came back enabled and got initialized, unlike
the same script going through the eager `initializeComponentData` path.

`_scriptsData` is only populated for scripts that came from component data, so
a script created through the public `create('later', { enabled: false })` had
its enabled state, attributes and assigned properties dropped as well - the
awaiting entry kept nothing but `awaiting` and `ind`. Keep all three on it, and
consume them both in the sweep and when cloning, so deferred creation ends up
with the same instance as eager creation would have.

The `attributes` local in the sweep was also declared outside the component
loop and only reassigned when the component had `_scriptsData`, so a component
without any declared data inherited the attributes of the previous component in
the loop. Read the data from a per-component local instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mvaligursky
mvaligursky force-pushed the mv-script-awaiting-enabled branch from 64fe7e0 to fa1aa39 Compare August 21, 2026 09:41
@mvaligursky
mvaligursky merged commit 6346b34 into main Aug 21, 2026
10 checks passed
@mvaligursky
mvaligursky deleted the mv-script-awaiting-enabled branch August 21, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant