supports event capturing and bubbling phases in Navigation context.

Summary: Adds the API that enables the navigation events capturing and bubbling which is the feature
that is enabled if the nested navigation contexts is created by the navigator.

This would allow developer to observe or reconcile navigation events within the navigation tree.

public

./Libraries/FBReactKit/jest

Reviewed By: zjj010104

Differential Revision: D2546451

fb-gh-sync-id: dfc9d16defaa563b9e80fd751a20570f6e524b74
This commit is contained in:
Hedger Wang 2015-10-19 10:03:04 -07:00 committed by facebook-github-bot-9
parent 1076f4a172
commit 73b80773ba
4 changed files with 431 additions and 35 deletions

View File

@ -26,30 +26,43 @@
*/ */
'use strict'; 'use strict';
var NavigationEvent = require('NavigationEvent');
var NavigationEventEmitter = require('NavigationEventEmitter'); var NavigationEventEmitter = require('NavigationEventEmitter');
var NavigationTreeNode = require('NavigationTreeNode'); var NavigationTreeNode = require('NavigationTreeNode');
var emptyFunction = require('emptyFunction'); var emptyFunction = require('emptyFunction');
var invariant = require('invariant'); var invariant = require('invariant');
import type * as NavigationEvent from 'NavigationEvent';
import type * as EventSubscription from 'EventSubscription'; import type * as EventSubscription from 'EventSubscription';
var {
AT_TARGET,
BUBBLING_PHASE,
CAPTURING_PHASE,
} = NavigationEvent;
/** /**
* Class that contains the info and methods for app navigation. * Class that contains the info and methods for app navigation.
*/ */
class NavigationContext { class NavigationContext {
__node: NavigationTreeNode; __node: NavigationTreeNode;
_eventEmitter: ?NavigationEventEmitter; _bubbleEventEmitter: ?NavigationEventEmitter;
_captureEventEmitter: ?NavigationEventEmitter;
_currentRoute: any; _currentRoute: any;
_emitCounter: number;
_emitQueue: Array<any>;
constructor() { constructor() {
this._eventEmitter = new NavigationEventEmitter(this); this._bubbleEventEmitter = new NavigationEventEmitter(this);
this._captureEventEmitter = new NavigationEventEmitter(this);
this._currentRoute = null; this._currentRoute = null;
// Sets the protected property `__node`. // Sets the protected property `__node`.
this.__node = new NavigationTreeNode(this); this.__node = new NavigationTreeNode(this);
this._emitCounter = 0;
this._emitQueue = [];
this.addListener('willfocus', this._onFocus, this); this.addListener('willfocus', this._onFocus, this);
this.addListener('didfocus', this._onFocus, this); this.addListener('didfocus', this._onFocus, this);
} }
@ -72,9 +85,12 @@ class NavigationContext {
addListener( addListener(
eventType: string, eventType: string,
listener: Function, listener: Function,
context: ?Object context: ?Object,
useCapture: ?boolean
): EventSubscription { ): EventSubscription {
var emitter = this._eventEmitter; var emitter = useCapture ?
this._captureEventEmitter :
this._bubbleEventEmitter;
if (emitter) { if (emitter) {
return emitter.addListener(eventType, listener, context); return emitter.addListener(eventType, listener, context);
} else { } else {
@ -83,19 +99,115 @@ class NavigationContext {
} }
emit(eventType: String, data: any, didEmitCallback: ?Function): void { emit(eventType: String, data: any, didEmitCallback: ?Function): void {
var emitter = this._eventEmitter; if (this._emitCounter > 0) {
if (emitter) { // An event cycle that was previously created hasn't finished yet.
emitter.emit(eventType, data, didEmitCallback); // Put this event cycle into the queue and will finish them later.
var args: any = Array.prototype.slice.call(arguments);
this._emitQueue.push(args);
return;
}
this._emitCounter++;
var targets = [this];
var parentTarget = this.parent;
while (parentTarget) {
targets.unshift(parentTarget);
parentTarget = parentTarget.parent;
}
var propagationStopped = false;
var defaultPrevented = false;
var callback = (event) => {
propagationStopped = propagationStopped || event.isPropagationStopped();
defaultPrevented = defaultPrevented || event.defaultPrevented;
};
// capture phase
targets.some((currentTarget) => {
if (propagationStopped) {
return true;
}
var extraInfo = {
defaultPrevented,
eventPhase: CAPTURING_PHASE,
propagationStopped,
target: this,
};
currentTarget.__emit(eventType, data, callback, extraInfo);
}, this);
// bubble phase
targets.reverse().some((currentTarget) => {
if (propagationStopped) {
return true;
}
var extraInfo = {
defaultPrevented,
eventPhase: BUBBLING_PHASE,
propagationStopped,
target: this,
};
currentTarget.__emit(eventType, data, callback, extraInfo);
}, this);
if (didEmitCallback) {
var event = NavigationEvent.pool(eventType, this, data);
propagationStopped && event.stopPropagation();
defaultPrevented && event.preventDefault();
didEmitCallback.call(this, event);
event.dispose();
}
this._emitCounter--;
while (this._emitQueue.length) {
var args: any = this._emitQueue.shift();
this.emit.apply(this, args);
} }
} }
dispose(): void { dispose(): void {
var emitter = this._eventEmitter; // clean up everything.
this._bubbleEventEmitter && this._bubbleEventEmitter.removeAllListeners();
this._captureEventEmitter && this._captureEventEmitter.removeAllListeners();
this._bubbleEventEmitter = null;
this._captureEventEmitter = null;
this._currentRoute = null;
}
// This method `__method` is protected.
__emit(
eventType: String,
data: any,
didEmitCallback: ?Function,
extraInfo: Object,
): void {
var emitter;
switch (extraInfo.eventPhase) {
case CAPTURING_PHASE: // phase = 1
emitter = this._captureEventEmitter;
break;
case BUBBLING_PHASE: // phase = 3
emitter = this._bubbleEventEmitter;
break;
default:
invariant(false, 'invalid event phase %s', extraInfo.eventPhase);
}
if (extraInfo.target === this) {
// phase = 2
extraInfo.eventPhase = AT_TARGET;
}
if (emitter) { if (emitter) {
// clean up everything. emitter.emit(
emitter.removeAllListeners(); eventType,
this._eventEmitter = null; data,
this._currentRoute = null; didEmitCallback,
extraInfo
);
} }
} }

View File

@ -36,13 +36,13 @@ class NavigationEventPool {
this._list = []; this._list = [];
} }
get(type: string, target: Object, data: any): NavigationEvent { get(type: string, currentTarget: Object, data: any): NavigationEvent {
var event; var event;
if (this._list.length > 0) { if (this._list.length > 0) {
event = this._list.pop(); event = this._list.pop();
event.constructor.call(event, type, target, data); event.constructor.call(event, type, currentTarget, data);
} else { } else {
event = new NavigationEvent(type, target, data); event = new NavigationEvent(type, currentTarget, data);
} }
return event; return event;
} }
@ -54,21 +54,57 @@ class NavigationEventPool {
var _navigationEventPool = new NavigationEventPool(); var _navigationEventPool = new NavigationEventPool();
/**
* The NavigationEvent interface represents any event of the navigation.
* It contains common properties and methods to any event.
*
* == Important Properties ==
*
* - target:
* A reference to the navigation context that dispatched the event. It is
* different from event.currentTarget when the event handler is called during
* the bubbling or capturing phase of the event.
*
* - currentTarget:
* Identifies the current target for the event, as the event traverses the
* navigation context tree. It always refers to the navigation context the
* event handler has been attached to as opposed to event.target which
* identifies the navigation context on which the event occurred.
*
* - eventPhase:
* Returns an integer value which specifies the current evaluation phase of
* the event flow; possible values are listed in NavigationEvent phase
* constants below.
*/
class NavigationEvent { class NavigationEvent {
static AT_TARGET: number;
static BUBBLING_PHASE: number;
static CAPTURING_PHASE: number;
static NONE: number;
_currentTarget: ?Object;
_data: any; _data: any;
_defaultPrevented: boolean; _defaultPrevented: boolean;
_propagationStopped: boolean;
_disposed: boolean; _disposed: boolean;
_target: ?Object; _propagationStopped: boolean;
_type: ?string; _type: ?string;
static pool(type: string, target: Object, data: any): NavigationEvent { target: ?Object;
return _navigationEventPool.get(type, target, data);
// Returns an integer value which specifies the current evaluation phase of
// the event flow.
eventPhase: number;
static pool(type: string, currentTarget: Object, data: any): NavigationEvent {
return _navigationEventPool.get(type, currentTarget, data);
} }
constructor(type: string, target: Object, data: any) { constructor(type: string, currentTarget: Object, data: any) {
this.target = currentTarget;
this.eventPhase = NavigationEvent.NONE;
this._type = type; this._type = type;
this._target = target; this._currentTarget = currentTarget;
this._data = data; this._data = data;
this._defaultPrevented = false; this._defaultPrevented = false;
this._disposed = false; this._disposed = false;
@ -81,8 +117,8 @@ class NavigationEvent {
} }
/* $FlowFixMe - get/set properties not yet supported */ /* $FlowFixMe - get/set properties not yet supported */
get target(): Object { get currentTarget(): Object {
return this._target; return this._currentTarget;
} }
/* $FlowFixMe - get/set properties not yet supported */ /* $FlowFixMe - get/set properties not yet supported */
@ -122,8 +158,10 @@ class NavigationEvent {
this._disposed = true; this._disposed = true;
// Clean up. // Clean up.
this.target = null;
this.eventPhase = NavigationEvent.NONE;
this._type = null; this._type = null;
this._target = null; this._currentTarget = null;
this._data = null; this._data = null;
this._defaultPrevented = false; this._defaultPrevented = false;
@ -132,4 +170,26 @@ class NavigationEvent {
} }
} }
/**
* Event phase constants.
* These values describe which phase the event flow is currently being
* evaluated.
*/
// No event is being processed at this time.
NavigationEvent.NONE = 0;
// The event is being propagated through the currentTarget's ancestor objects.
NavigationEvent.CAPTURING_PHASE = 1;
// The event has arrived at the event's currentTarget. Event listeners registered for
// this phase are called at this time.
NavigationEvent.AT_TARGET = 2;
// The event is propagating back up through the currentTarget's ancestors in reverse
// order, starting with the parent. This is known as bubbling, and occurs only
// if event propagation isn't prevented. Event listeners registered for this
// phase are triggered during this process.
NavigationEvent.BUBBLING_PHASE = 3;
module.exports = NavigationEvent; module.exports = NavigationEvent;

View File

@ -30,14 +30,15 @@
var EventEmitter = require('EventEmitter'); var EventEmitter = require('EventEmitter');
var NavigationEvent = require('NavigationEvent'); var NavigationEvent = require('NavigationEvent');
type EventParams = { type ExtraInfo = {
data: any; defaultPrevented: ?boolean,
didEmitCallback: ?Function; eventPhase: ?number,
eventType: string; propagationStopped: ?boolean,
target: ?Object,
}; };
class NavigationEventEmitter extends EventEmitter { class NavigationEventEmitter extends EventEmitter {
_emitQueue: Array<EventParams>; _emitQueue: Array<any>;
_emitting: boolean; _emitting: boolean;
_target: Object; _target: Object;
@ -51,18 +52,38 @@ class NavigationEventEmitter extends EventEmitter {
emit( emit(
eventType: string, eventType: string,
data: any, data: any,
didEmitCallback: ?Function didEmitCallback: ?Function,
extraInfo: ?ExtraInfo
): void { ): void {
if (this._emitting) { if (this._emitting) {
// An event cycle that was previously created hasn't finished yet. // An event cycle that was previously created hasn't finished yet.
// Put this event cycle into the queue and will finish them later. // Put this event cycle into the queue and will finish them later.
this._emitQueue.push({eventType, data, didEmitCallback}); var args: any = Array.prototype.slice.call(arguments);
this._emitQueue.unshift(args);
return; return;
} }
this._emitting = true; this._emitting = true;
var event = new NavigationEvent(eventType, this._target, data); var event = NavigationEvent.pool(eventType, this._target, data);
if (extraInfo) {
if (extraInfo.target) {
event.target = extraInfo.target;
}
if (extraInfo.eventPhase) {
event.eventPhase = extraInfo.eventPhase;
}
if (extraInfo.defaultPrevented) {
event.preventDefault();
}
if (extraInfo.propagationStopped) {
event.stopPropagation();
}
}
// EventEmitter#emit only takes `eventType` as `String`. Casting `eventType` // EventEmitter#emit only takes `eventType` as `String`. Casting `eventType`
// to `String` to make @flow happy. // to `String` to make @flow happy.
@ -76,8 +97,8 @@ class NavigationEventEmitter extends EventEmitter {
this._emitting = false; this._emitting = false;
while (this._emitQueue.length) { while (this._emitQueue.length) {
var arg = this._emitQueue.shift(); var args: any = this._emitQueue.shift();
this.emit(arg.eventType, arg.data, arg.didEmitCallback); this.emit.apply(this, args);
} }
} }
} }

View File

@ -29,6 +29,7 @@ jest
.mock('ErrorUtils'); .mock('ErrorUtils');
var NavigationContext = require('NavigationContext'); var NavigationContext = require('NavigationContext');
var NavigationEvent = require('NavigationEvent');
describe('NavigationContext', () => { describe('NavigationContext', () => {
it('defaults `currentRoute` to null', () => { it('defaults `currentRoute` to null', () => {
@ -48,4 +49,206 @@ describe('NavigationContext', () => {
parent.appendChild(child); parent.appendChild(child);
expect(child.parent).toBe(parent); expect(child.parent).toBe(parent);
}); });
it('captures event', () => {
var parent = new NavigationContext();
var child = new NavigationContext();
parent.appendChild(child);
var logs = [];
var listener = (event) => {
var {currentTarget, eventPhase, target, type} = event;
logs.push({
currentTarget,
eventPhase,
target,
type,
});
};
parent.addListener('yo', listener, null, true);
child.addListener('yo', listener, null, true);
child.emit('yo');
expect(logs).toEqual([
{
currentTarget: parent,
eventPhase: NavigationEvent.CAPTURING_PHASE,
target: child,
type: 'yo',
},
{
currentTarget: child,
eventPhase: NavigationEvent.AT_TARGET,
target: child,
type: 'yo',
}
]);
});
it('bubbles events', () => {
var parent = new NavigationContext();
var child = new NavigationContext();
parent.appendChild(child);
var logs = [];
var listener = (event) => {
var {currentTarget, eventPhase, target, type} = event;
logs.push({
currentTarget,
eventPhase,
target,
type,
});
};
parent.addListener('yo', listener);
child.addListener('yo', listener);
child.emit('yo');
expect(logs).toEqual([
{
currentTarget: child,
eventPhase: NavigationEvent.AT_TARGET,
target: child,
type: 'yo',
},
{
currentTarget: parent,
eventPhase: NavigationEvent.BUBBLING_PHASE,
target: child,
type: 'yo',
},
]);
});
it('stops event propagation at capture phase', () => {
var parent = new NavigationContext();
var child = new NavigationContext();
parent.appendChild(child);
var counter = 0;
parent.addListener('yo', event => event.stopPropagation(), null, true);
child.addListener('yo', event => counter++, null, true);
child.emit('yo');
expect(counter).toBe(0);
});
it('stops event propagation at bubbling phase', () => {
var parent = new NavigationContext();
var child = new NavigationContext();
parent.appendChild(child);
var counter = 0;
parent.addListener('yo', event => counter++);
child.addListener('yo', event => event.stopPropagation());
child.emit('yo');
expect(counter).toBe(0);
});
it('prevents event at capture phase', () => {
var parent = new NavigationContext();
var child = new NavigationContext();
parent.appendChild(child);
var val;
parent.addListener('yo', event => event.preventDefault(), null, true);
child.addListener('yo', event => val = event.defaultPrevented, null, true);
child.emit('yo');
expect(val).toBe(true);
});
it('prevents event at bubble phase', () => {
var parent = new NavigationContext();
var child = new NavigationContext();
parent.appendChild(child);
var val;
parent.addListener('yo', event => val = event.defaultPrevented);
child.addListener('yo', event => event.preventDefault());
child.emit('yo');
expect(val).toBe(true);
});
it('emits nested events in order at capture phase', () => {
var parent = new NavigationContext();
var child = new NavigationContext();
parent.appendChild(child);
var logs = [];
var listener = (event) => {
var {currentTarget, type} = event;
logs.push({
currentTarget,
type,
});
};
child.addListener('yo', event => {
// event `didyo` should be fired after the full propagation cycle of the
// `yo` event.
child.emit('didyo');
});
parent.addListener('yo', listener, null, true);
parent.addListener('didyo', listener, null, true);
child.addListener('yo', listener, null, true);
child.emit('yo');
expect(logs).toEqual([
{type: 'yo', currentTarget: parent},
{type: 'yo', currentTarget: child},
{type: 'didyo', currentTarget: parent},
]);
});
it('emits nested events in order at bubbling phase', () => {
var parent = new NavigationContext();
var child = new NavigationContext();
parent.appendChild(child);
var logs = [];
var listener = (event) => {
var {currentTarget, type} = event;
logs.push({
currentTarget,
type,
});
};
child.addListener('yo', event => {
// event `didyo` should be fired after the full propagation cycle of the
// `yo` event.
child.emit('didyo');
});
parent.addListener('yo', listener);
child.addListener('yo', listener);
parent.addListener('didyo', listener);
child.emit('yo');
expect(logs).toEqual([
{type: 'yo', currentTarget: child},
{type: 'yo', currentTarget: parent},
{type: 'didyo', currentTarget: parent},
]);
});
}); });