-
Notifications
You must be signed in to change notification settings - Fork 1.4k
EventHandle and option to disable EventHandler chaining. #5481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
const handles = this._callbacks[name];
if (handles) {
for (let i = 0; i < handles.length; i++) {
handles[i].destroy();
}
this._callbacks[name] = [];
} Shouldn't Why do we produce Array GC? Here too: for(let i = 0; i < this.events.length; i++) {
this.events[i].off();
}
this.events = []; |
src/core/event-handle.js
Outdated
destroy() { | ||
if (this._removed) return; | ||
this._removed = true; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't a destroyed handle off
itself at least?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
destroy
is a private method of EventHandle, and cleans its data, and does not modify data outside of its class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every other destroy
method we have cleans up its events in other objects, usually in the form of:
destroy() {
// remove any outstanding listeners
this._registry.off("load", this._onLoad);
this._registry.off("error", this._onError);
// ...
}
Why is this a special case?
It also doesn't really do anything at all right now, is it a draft?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every other
destroy
method we have cleans up its events in other objects, usually in the form of:destroy() { // remove any outstanding listeners this._registry.off("load", this._onLoad); this._registry.off("error", this._onError); // ... }Why is this a special case?
It also doesn't really do anything at all right now, is it a draft?
Events are "detached" rather than "destroyed". This PR does not change that and follows an existing pattern.
I've added clear references to destroy, like this.callback = null
and other things, but tests did not like that. As the current implementation does not clear such references from event objects, their properties are accessed after during tests. So such change would have the potential to break.
Focus of this PR is to with minimal logic changes implement EventHandle. If there is an optimisation of EventHandler methods - good option for another PR. |
Good point, what about the code that specifically you added? for(let i = 0; i < this.events.length; i++) {
this.events[i].off();
}
this.events = []; * @example
* // store an array of event handles
* let events = [ ];
*
* events.push(objA.on('testA', () => { }));
* events.push(objB.on('testB', () => { }));
*
* // when needed, remove all events
* events.forEach((evt) => {
* evt.off();
* });
* events = [ ]; Usually we handle it like this in the engine: destroy() {
const meshInstances = this.meshInstances;
for (let i = 0; i < meshInstances.length; i++) {
meshInstances[i].destroy();
}
this.meshInstances.length = 0;
} Because why create extra GC for operations that don't require it? We use temporary variables everywhere aswell to not call |
What would be an alternative way of unsubscribing from a list of events?
I've updated the example to use Events are "detached" and long-lived objects. The usual pattern of working with them is to have them for a long time, and sometimes remove them. Associated memory with each event is small. So re-using event handles is not considered here. Vectors and similar math objects can be created on each frame, so this can lead to a significant GC stall, and we do encourage re-using them, as they can be re-used. EventHandle - cannot be re-used, like many other things within the engine. |
Updated this PR with a recent EventHandler refactor (#5501). |
/** | ||
* True if event has been removed. | ||
* @type {boolean} | ||
*/ | ||
get removed() { | ||
return this._removed; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really need all this extra getter code which kinda does nothing? Why not simple and short:
/**
* True if event has been removed.
* @type {boolean}
* @readonly
*/
removed = false;
If I get your intention right, you just want to have it readable and not writable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I just realized we still lack proper types to express this idiom: microsoft/TypeScript#37487
My favorite would be this, which is a pattern all over the engine:
~~
destroy() {
this.handler?.off(this.name, this.callback, this.scope);
this.handler = null;
}
~~
So we don't introduce removed
/_removed
at all.
Edit: outdated / do-not-use / etc.
Right, so why give them an My main motivation to get rid of the I think we could simply return always and only the So for the rare case that someone wrote something like: this._registry
.off("load", this._onLoad)
.off("error", this._onError); They can continue using that code until we decide it had enough deprecation time. |
That is the current naming convention when working with events. We "off" them. const evt = app.on('event', onEvent);
app.off('event', onEvent);
evt.off(); // alternative
// or
app.off('event', onEvent);
evt.destroy(); // alternative
I absolutely agree here. But, the engine has a promise to the users that it does not break public APIs, and ensures backwards compatibility. Even with this case, where I personally never seen chaining used in dozens of projects I've worked on.
It will be impossible to have the same behavior, as currently the returned value from EventHandler.on - is the EventHandler itself. So the user can access any properties/methods of EventHandler (and objects that inherit it, e.g. Entity). Also,
Will work with this PR. |
Okay, lets be real here: breaking things is an established procedure, that's why we note it in releases: The While this PR is basically obfuscating API by trying to bend over backwards for something that affects... no one? In my personal fork I ripped the Event chaining out quite some time ago, never experienced any problems in projects I play around with. |
@willeastcott, @slimbuck, @mvaligursky what you guys think, should we go with breaking change by returning EventHandle from That would be definitely amazing, it just goes against a rule of "never break a public API". I went conservative with this PR, but am happy to go with breaking (unlikely affecting anyone) change. |
To be honest, @Maksims, I never knew you could chain these calls and I never have in all the years I've been working with PlayCanvas. I don't recall ever seeing anyone else do that in their code (which doesn't mean they don't do it!). In this particular instance, where I believe very, very few projects will be affected, I'd rather go with a clean API and a mini-breakage. 😄 What do you think @mvaligursky? |
@Maksims - I agree with @willeastcott , that's a main reason to break existing API. I'd say go for it as well, considering this will benefit many users with some inconvenience to almost none. |
This still needs input, I would rather completely drop I don't want to write code like |
I've updated PR with returning EventHandle by default. |
* @param {object} scope - Object that is used as `this` when event is fired. | ||
* @param {boolean} [once] - If this is a single event and will be removed after event is fired. | ||
*/ | ||
constructor(handler, name, callback, scope, once = false) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we hide the constructor from the docs? Presumably end users would never construct the EventHandle
themselves and just use instances returned from on
and once
?
} | ||
|
||
once(name, callback, scope = this) { | ||
Debug.deprecated('Using chaning with EventHandler.once is deprecated, subscribe to an event from EventHandler directly instead.'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Debug.deprecated('Using chaning with EventHandler.once is deprecated, subscribe to an event from EventHandler directly instead.'); | |
Debug.deprecated('Using chaining with EventHandler.once is deprecated, subscribe to an event from EventHandler directly instead.'); |
@@ -111,7 +119,7 @@ class EventHandler { | |||
* the callback is limited to 8 arguments. | |||
* @param {object} [scope] - Object to use as 'this' when the event is fired, defaults to | |||
* current this. | |||
* @returns {EventHandler} Self for chaining. | |||
* @returns {EventHandle} - can be used for removing event in the future. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
* @returns {EventHandle} - can be used for removing event in the future. | |
* @returns {EventHandle} Can be used for removing event in the future. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, nice job here. I'm approving with a few minor fixes and suggestions. But I do kinda wish there were some unit tests to go along with this PR. 😉
I'm also very happy about the outcome, I would just like to point out the last thing that I'm still not happy with: I have a function that I'm using in dozens of places: /**
* Call each element's destroy method and empty array afterwards.
* @param {{destroy: Function}[]} arr
*/
export function destroyArray(arr) {
const n = arr.length;
for (let i = 0; i < n; i++) {
arr[i].destroy();
}
arr.length = 0;
} When I call that on an array, I expect that no resource is leaked any longer. But current You can check every They all clean up their events in their Instead of something like this: // detach all linked events
Script.prototype.detachEvents = function() {
if (!this.events?.length) return;
for(let i = 0; i < this.events.length; i++) {
this.events[i].off();
}
this.events.length = 0;
}; I just write everywhere: // detach all linked events
Script.prototype.detachEvents = function() {
if (!this.events?.length) return;
destroyArray(this.events);
}; Collapsing 4 lines into 1... So my last change proposal is: And the four lines of In C++ there is a concept of "friend classes", but in JS it's simply /**
* True if event has been removed.
* @type {boolean}
* @internal
*/
_removed = false; And then you can drop the getter: /**
* True if event has been removed.
* @type {boolean}
*/
get removed() {
return this._removed;
} These changes don't break anything, the unit tests work etc, it's a semantic fix to ensure that destroying means destroying and not marking something as removed, while leaking its resources. |
Co-authored-by: Will Eastcott <[email protected]>
Co-authored-by: Will Eastcott <[email protected]>
Co-authored-by: Will Eastcott <[email protected]>
I've updated that function for you: export function destroyArray(arr) {
const n = arr.length;
for (let i = 0; i < n; i++) {
if (arr[i] instanceof pc.EventHandle) {
arr[i].off();
} else {
arr[i].destroy();
}
}
arr.length = 0;
} The initial intent was to clean up references within the |
@Maksims Thank you a lot, it looks very good! Can we just please have a simple destroy method? 😅 This is a common pattern: engine/src/framework/asset/asset-localized.js Lines 217 to 221 in 864b4bd
We could apply that here aswell: destroy() {
this.off();
} Yes, I could update Anyway, if you add that or not, I'm very happy about the state 👍 Good job! 💯 |
I just synched with @Maksims and he says he's happy to merge this in its current state. So merging... 😄 Such a great contribution. Thank you all! |
Just a heads up. This PR breaks the current playcanvas tween add on when you tween like I have in the code snippet below since the event handlers no longer return this but they return the new EventHandle.
|
@Maksims - any thoughts on the comment above? |
I wanted to look into this all day too, but working on something else... quick ad-hoc solution should be a prototype patch and return |
Small patch of tween library |
We are also mixing "illegal" ES5 patterns with ES6 in We are not coming around the fact that we have to have some breakage in old ways of doing things, because it's simply not possible any longer - ES6 is here since 2015 and we didn't catch up too much in PC libraries (tween / spine lib). The upside is that we can get a somewhat simpler typing experience too by following standard ES6 ways (compared to namespace merging in e.g. And ES6 users could use whatever, like If we already need to update two things, we can just overlap it so developers only need to update once and are "good to go" again. |
With a second glance at a tween library recommended ways of doing things, and it heavily relies on chaining. This is the case where not forcing breaking change, but having it behind the flag would help. Updating the tween library with its own methods like
This is outside of this PR's scope, please open a separate issue or propose a PR in a related repo. UPDATE: |
This PR introduces
EventHandle
for easier events management.Problem:
Event management with limited lifetime of objects - can be cumbersome, especially as there is no easy way to detach events unless you have all the references to arguments that this event was created with.
Also, anonymous functions as a callback on event that needs to be removed after - is a "no no!" with a current implementation.
So when you need to subscribe to an event in a script, and manage that subscription, you need to add very similar lines to 3 places: initialize, destroy and swap. And when you have many events, it looks like copy-pasted code all around with small modifications.
Another major problem is that current EventHandler implementation returns
this
when creating an event usingon
andonce
, which historically was called "chaining", but it is not chaining as it does not offer any logic of chaining events, their order of execution and rules of execution.So implementing EventHandle requires breaking change, which PlayCanvas avoids. As chaining has not been seen used in practice, this PR implements it in a breaking manner, to mitigate issues if someone (extremely unlikely) used chaining, this implementation makes a
on
andonce
method for backwards compatibility. So this code will still work:Notes:
Engine does not have a single use of "chaining" in its codebase or examples.
I've tested this PR on dozens of existing projects (old and new, small and big), and have not found a single use of "chaining".
This also been discussed on forums and on github: #5341 #4910
During tests, engine uses somewhere references for event handle (old object) after it has been removed.
Solution:
Proposed implementation changes the behavior of
on
andonce
onEventHandler
, by returningEventHandle
. Which developer can use to detach event by simply callinghandle.off()
.New APIs:
Examples:
ScriptType remove event when script is destroyed or swapped:
ScriptType with many events:
I confirm I have read the contributing guidelines and signed the Contributor License Agreement.