Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

fix($rootScope): avoid unstable reference in $broadcast event #7445

Closed
wants to merge 3 commits into from

Conversation

ncuillery
Copy link
Contributor

Request Type: bug

How to reproduce: Any case of asynchronous access to event.currentScope :

  • console.log(event) then expand the object in console (Chrome / Firefox)
  • Via $timeout
  • In Jasmine expectation

Plunker

Note : The tree of scopes must have a depth greater than 2 (see commit diff for rootScopeSpec.js).

Component(s): scope

Impact: small

Complexity: small

This issue is related to:

Detailed Description:

In the $broadcast method, the event.currentScope is initialized with current which is the iterator used by the scope traversal's loop.

It caused problems when using asynchonously the event object : the event.currentScope is not the scope which listen the event. It's the last scope crossed by the traversal which has nothing to do with the event (or coincidentally has).

Other Comments:

The event.currentScope was a reference to the iterator of the scope traversal (`current`). It caused problems when accessing the event.currentScope asynchronously : it was the last scope of the traversal instead of the scope which listen the event.
@mary-poppins
Copy link

Thanks for the PR! Please check the items below to help us merge this faster. See the contributing docs for more information.

  • Uses the issue template (#7445)

If you need to make changes to your pull request, you can update the commit with git commit --amend.
Then, update the pull request with git push -f.

Thanks again for your help!

@lgalfaso
Copy link
Contributor

@ncuillery Thanks for the PR. This does look like a real bug, anyhow, I have a few comments

  • if we fix $broadcast, then the same should be done for $emit

  • it needs tests :)

  • it may have some performance implications

    Thanks again

@lgalfaso lgalfaso removed their assignment May 13, 2014
@ncuillery
Copy link
Contributor Author

Right, I saw $emit and I didn't observe the bug in this method (there is no scope traversal), but I agree that having a different syntax in $emit and $broadcast is bad (I hesitated for a long time to refactor :) ).

About testing, I broke the listener should receive event object test case by simply adding a grand-child in the tree of scopes (See diff in rootScopeSpec.js). In my mind, the test case is relevant because we are immediately in a reproducing situation (jasmine expectations are asynchronous). If the test succeed again, it means that the bug is fixed.

I gonna play with jsPerf.

@lgalfaso
Copy link
Contributor

@ncuillery if I apply only the changes to rootScopeSpec.js to the current master, then all tests pass. If this patch is meant to fix an issue, it will need a test that fail without the patch and passes with the patch

}
Event.prototype.preventDefault = function() {
this.defaultPrevented = true;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the purpose of this function?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It defines a javascript class which is instantiated for each listener among the tree of scopes.

By this way, each listener received a dedicated event with a stable currentScope property.

Copy link
Contributor

Choose a reason for hiding this comment

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

But why does this class need a preventDefault? There is not action being taken there other then altering this.defaultPrevented. This seems misleading to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I retained the existing preventDefault.

I asked myself for the purpose of this property. Perhaps to be consistent with DOM events.

Copy link
Contributor

Choose a reason for hiding this comment

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

I feel it should be removed, this may have been a mistake in the angular api. Events without a native default should not have a preventDefault method. @lgalfaso thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think they should have preventDefault even when in the case of a $broadcast it does nothing

Copy link
Contributor

Choose a reason for hiding this comment

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

@lgalfaso why? I don't believe it does anything for $emit either. Its just basically a misleading noop.

Copy link
Contributor

Choose a reason for hiding this comment

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

@Fresheyeball the event used to have a function called preventDefault and that should not be removed, user may call it (even if it does nothing).
In the case of $emit, calling preventDefault does prevent the event from going up

Copy link
Contributor

Choose a reason for hiding this comment

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

@lgalfaso thats even worse. Why would preventDefault be stopPropagation? There is no browser default, so there should be no prevent default. That is exactly why this is bad.

Copy link
Contributor

Choose a reason for hiding this comment

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

@Fresheyeball There are two things involved there

This PR is to fix an existing issue with events current parameter (that affects $broadcast and $emit). As part of this fix, it is required that this does not break what people expect from the API, this includes that there is a function called preventDefault. How this function works is not part of this PR.

Now, preventDefault is part of the DOM standard http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-Event-preventDefault. This patch is not about adding/removing or being consistent on the fact that there is a preventDefault function and not stopPropagation nor on the fact that preventDefault works in one way when calling $broadcast and in another way when calling $emit. If you feel that this is a limitation you are welcome to put a PR in place

ncuillery added a commit to ncuillery/angular.js that referenced this pull request May 14, 2014
Travis build should be failed with this commit (meaning that the test case in failure is relevant for the bug seen in angular#7445).

MUSTN'T BE MERGE
@ncuillery
Copy link
Contributor Author

@lgalfaso Are you sure ? ;) I create a "trashable" PR with only the change on rootScopeSpec.js and it failed exactly as I expect :

Scope events $broadcast listener should receive event object FAILED
    Expected { ..., $id : '003', ... } to be { ..., $id : '002', ... }.

The currentScope is the grandChild (003, end of the scope traversal) instead of the child (002, listening to the event).

PR
Commit
Travis build

In current master, the currentScope is 002 because it's the scope listening to event and, by chance, the end of the scope traversal. But it's an "abstract" particular case (AngularJS apps rarely have only two scopes). That's why I'm a bit reluctant to add a new test case.

I can add a test case with both synchronous and asynchronous expectations but it will globally look like the existing one with 3 scopes instead of 2 and a different description in it function...

@lgalfaso
Copy link
Contributor

@ncuillery you are right, the test fails with the new scope, my bad. There are two aspects to be talked about

  • There might be apps that make use of the fact that the same event object was used throughout the $broadcast
  • The patch creates a new array and a new Event per call, this may have some performance implications

On top, there is a real issue with $emit, if this patch moves forward, then $emit needs a similar fix

@ncuillery
Copy link
Contributor Author

Yes indeed, I was wrong when I said there is no bug in $emit. The currentScope is unstable too :
Plunker

I'm working on a fix for $emit, in the same way as $broadcast for the moment (so potentially with performance impacts).

The event.currentScope in `$emit` method was a reference to an iterator. When accessing asynchronously, the event.currentScope was the $rootScope (end of the do/while loop) instead of the scope which listen the event.

The `$emit` and `$broadcast` methods use the same syntax now. Both methods are tested with :
- sync/async access to `event.currentScope` and `event.targetScope`.
- a test case for `preventDefault()` and `defaultPrevented`.
ncuillery added a commit to ncuillery/angular.js that referenced this pull request May 16, 2014
They are modified version of $emit and $broadcast (see angular#7445)
@ncuillery
Copy link
Contributor Author

Now $broadcast and $emit are both fixed and well-tested. Their syntax are consistent. @lgalfaso Do you approve ? (see bd131bb)

I investigated performances implications with jsperf:

The test cases call the new and the original $broadcast method many times on a complete B-tree of ~4000 scopes (5-B-tree with a deep of 6).

I change the number of listeners in different test cases (the tree is the same):

I think the huge lack of perfs (when there is little or no listener) is not relevant because jsperf calibrate the number of execution from the average time of execution. In the 0-listener test case, Chrome still handle 130k calls to $broadcast per second. I suppose it doesn't reflect reality... (even the unique listener test case with 10k $broadcast per seconds...)

The results are greatly influenced by the content of the listeners because their execution time is included in the measured time. Tests above have a console.log in the listener.

The results are very unprofitable when listeners are empty:

At the opposite, they become better when the listener changes a variable of the scope (a common use case):

I objectively think that the updated $broadcast method is slower but the situations where it can be critical are consequences of a bad-form code. (mass calls to $broadcast, empty listeners, ...)

I hope this will help. I'm not really a JS perf tester, so feel free to analyze and criticize this post. I present only time-based tests, should I do memory-based tests ? with Chrome dev tools ?.

Others tests for $emit will soon be here.

@caitp
Copy link
Contributor

caitp commented May 20, 2014

why create Event twice? what does that gain?

event = e;
});
scope.$broadcast('fooEvent');

expect(event.name).toBe('fooEvent');
// Asynchronous expectation : current & target should be the same as above
Copy link
Contributor

Choose a reason for hiding this comment

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

wat

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just moved this expectation few lines above (in child.$on listener function L1725).

I made this to be consistent with the existing similar test case for $emit L1570.

Copy link
Contributor

Choose a reason for hiding this comment

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

it's not asynchronous, and you should not give the impression that it's asynchronous

I made this to be consistent with the existing similar test case for $emit

That's a test case you wrote yourself, it's not present in angular core! Just remove the "Asynchronous expectation..." bit

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

You understand that I'm referring to the comment, right?

That scope event handler is not being called asynchronously, there is nothing asynchronous happening here, it's all within a single turn of the event loop

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops. No, I didn't. Sorry for that.

@caitp
Copy link
Contributor

caitp commented May 20, 2014

If I understand this, you're creating a bunch of garbage (to be collected) for each scope an event bubbles over to, and this is not likely to have any kind of performance or usability benefit. What it's going to do is cause the GC to have a lot of work to do, which will cause pauses in the application

What are you trying to accomplish here?

It caused problems when using asynchonously the event object : the event.currentScope is not the scope which listen the event.

This is a non-issue, just save currentScope to a variable in your event handler. Event dispatch is synchronous, you should expect it to be treated as synchronous.

Further, currentScope should nearly always just refer to whatever $scope you happen to have on hand, so you have a stable reference to it there regardless

@ncuillery
Copy link
Contributor Author

If I understand this, you're creating a bunch of garbage (to be collected) for each scope an event bubbles over to, and this is not likely to have any kind of performance or usability benefit. What it's going to do is cause the GC to have a lot of work to do, which will cause pauses in the application

During the traversal ($broadcast) or the bubbling ($emit), there is no memory implication for scopes which don't listen the event.

This is a non-issue, just save currentScope to a variable in your event handler.

I never said there is no work-around. I just find that it could be painful for a developer to understand what is happening when the currentScope is not the one you are looking for.

The way the currentScope works is misleading. Before my PR, the Scope events $broadcast listener should receive event object passed by sheer coincidence (with the tree of 2 scopes, the scope which listen the event is also the one referred by currentScope at the end of traversal/propagation). See #7454.

Event dispatch is synchronous, you should expect it to be treated as synchronous.

Maybe the docs should mention that.

@caitp
Copy link
Contributor

caitp commented May 20, 2014

Event dispatch is synchronous, you should expect it to be treated as synchronous.

Maybe the docs should mention that.

PRs welcome 😺

@IgorMinar
Copy link
Contributor

Sorry guys, but I think that this is a wrong approach. If the developer cares about the currentScope being the right thing in an asynchronous context then the developer needs to grab the reference when the even is delivered to them.

The scope event system is modeled as a simplified DOM event system, so currentScope and targetScope are analogous for currentTarget and target. In case of DOM the currentTarget is simply set to null when the event is done propagating. And this is exactly what we should do as well.

IgorMinar added a commit to IgorMinar/angular.js that referenced this pull request May 20, 2014
When a event is finished propagating through Scope hierarchy the event's `currentScope` property
should be reset to `null` to avoid accidental use of this property in asynchronous event handlers.

In the previous code, the event's property would contain a reference to the last Scope instance that
was visited during the traversal, which is unlikely what the code trying to grab scope reference expects.

BREAKING CHANGE: $broadcast and $emit will now reset the `currentScope` property of the event to
null once the event finished propagating. If any code depends on asynchronously accessing thei
`currentScope` property, it should be migrated to use `targetScope` instead. All of these cases
should be considered programming bugs.

Closes angular#7445"
IgorMinar added a commit to IgorMinar/angular.js that referenced this pull request May 20, 2014
When a event is finished propagating through Scope hierarchy the event's `currentScope` property
should be reset to `null` to avoid accidental use of this property in asynchronous event handlers.

In the previous code, the event's property would contain a reference to the last Scope instance that
was visited during the traversal, which is unlikely what the code trying to grab scope reference expects.

BREAKING CHANGE: $broadcast and $emit will now reset the `currentScope` property of the event to
null once the event finished propagating. If any code depends on asynchronously accessing thei
`currentScope` property, it should be migrated to use `targetScope` instead. All of these cases
should be considered programming bugs.

Closes angular#7445
@IgorMinar
Copy link
Contributor

I have a fix pr here: #7523 can someone please review?

@IgorMinar IgorMinar added this to the Purgatory milestone May 20, 2014
@ncuillery
Copy link
Contributor Author

@IgorMinar This explanation suits me well, and your fix solves the ambiguous situations I describe.

@IgorMinar IgorMinar closed this in 82f45ae May 21, 2014
@ncuillery ncuillery deleted the fix branch May 23, 2014 07:12
RichardLitt pushed a commit to RichardLitt/angular.js that referenced this pull request May 24, 2014
When a event is finished propagating through Scope hierarchy the event's `currentScope` property
should be reset to `null` to avoid accidental use of this property in asynchronous event handlers.

In the previous code, the event's property would contain a reference to the last Scope instance that
was visited during the traversal, which is unlikely what the code trying to grab scope reference expects.

BREAKING CHANGE: $broadcast and $emit will now reset the `currentScope` property of the event to
null once the event finished propagating. If any code depends on asynchronously accessing thei
`currentScope` property, it should be migrated to use `targetScope` instead. All of these cases
should be considered programming bugs.

Closes angular#7445
Closes angular#7523
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants