-
Notifications
You must be signed in to change notification settings - Fork 48.9k
[WIP] React Native: Turn HostComponent into an EventEmitter #23278
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
Changes from all commits
a33b351
83e9bc9
d73dd66
d35b766
29bf6a4
049a50b
88cfd64
f79c3fc
7f2a1b3
7f3a82c
5a8047d
3f38d4a
1055e56
f1746d2
df4a1fd
e5f5199
901742a
a78b098
633f9eb
7adec88
5bc7103
e50bf63
f01bd0c
d1c0843
050211d
b37c1af
a994a3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,6 +10,7 @@ | |
import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes'; | ||
import type {ElementRef} from 'react'; | ||
import type { | ||
CustomEvent, | ||
HostComponent, | ||
MeasureInWindowOnSuccessCallback, | ||
MeasureLayoutOnSuccessCallback, | ||
|
@@ -95,6 +96,19 @@ export type RendererInspectionConfig = $ReadOnly<{| | |
) => void, | ||
|}>; | ||
|
||
// TODO: find a better place for this type to live | ||
export type EventListenerOptions = $ReadOnly<{| | ||
capture?: boolean, | ||
once?: boolean, | ||
passive?: boolean, | ||
signal: mixed, // not yet implemented | ||
|}>; | ||
export type EventListenerRemoveOptions = $ReadOnly<{| | ||
capture?: boolean, | ||
|}>; | ||
// TODO: this will be changed in the future to be w3c-compatible and allow "EventListener" objects as well as functions. | ||
export type EventListener = Function; | ||
|
||
// TODO: Remove this conditional once all changes have propagated. | ||
if (registerEventHandler) { | ||
/** | ||
|
@@ -103,6 +117,14 @@ if (registerEventHandler) { | |
registerEventHandler(dispatchEvent); | ||
} | ||
|
||
type InternalEventListeners = { | ||
[string]: {| | ||
listener: EventListener, | ||
options: EventListenerOptions, | ||
invalidated: boolean, | ||
|}[], | ||
}; | ||
|
||
/** | ||
* This is used for refs on host components. | ||
*/ | ||
|
@@ -111,6 +133,7 @@ class ReactFabricHostComponent { | |
viewConfig: ViewConfig; | ||
currentProps: Props; | ||
_internalInstanceHandle: Object; | ||
_eventListeners: ?InternalEventListeners; | ||
|
||
constructor( | ||
tag: number, | ||
|
@@ -122,6 +145,7 @@ class ReactFabricHostComponent { | |
this.viewConfig = viewConfig; | ||
this.currentProps = props; | ||
this._internalInstanceHandle = internalInstanceHandle; | ||
this._eventListeners = null; | ||
} | ||
|
||
blur() { | ||
|
@@ -193,6 +217,101 @@ class ReactFabricHostComponent { | |
|
||
return; | ||
} | ||
|
||
// This API (dispatchEvent, addEventListener, removeEventListener) attempts to adhere to the | ||
// w3 Level2 Events spec as much as possible, treating HostComponent as a DOM node. | ||
// | ||
// Unless otherwise noted, these methods should "just work" and adhere to the W3 specs. | ||
// If they deviate in a way that is not explicitly noted here, you've found a bug! | ||
// | ||
// See: | ||
// * https://www.w3.org/TR/DOM-Level-2-Events/events.html | ||
// * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent | ||
// * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener | ||
// * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener | ||
dispatchEvent_unstable(event: CustomEvent) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding stuff to the prototype should be ok, I guess. Feels a bit strange that these instances are a mix of "private" things like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Originally we designed this object to be completely removed so that we could reclaim the memory and to optimize calls by removing virtual dispatch. Eg by introducing apis like focus(ref) instead. These other methods were just there for backwards compatibility. That was never followed through and so this was never deleted. So this direction seems like a reversal of that strategy and to keep living with more memory usage and worse performance. If that’s the direction then there are probably more things that should be cleaned up. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The other option was to do |
||
dispatchEvent(this._internalInstanceHandle, event.type, event); | ||
} | ||
|
||
// Deviations from spec/TODOs: | ||
// (1) listener must currently be a function, we do not support EventListener objects yet. | ||
// (2) we do not support the `signal` option / AbortSignal yet | ||
addEventListener_unstable( | ||
eventType: string, | ||
listener: EventListener, | ||
options: EventListenerOptions | boolean, | ||
) { | ||
if (typeof eventType !== 'string') { | ||
throw new Error('addEventListener_unstable eventType must be a string'); | ||
} | ||
if (typeof listener !== 'function') { | ||
throw new Error('addEventListener_unstable listener must be a function'); | ||
} | ||
|
||
// The third argument is either boolean indicating "captures" or an object. | ||
const optionsObj = | ||
typeof options === 'object' && options !== null ? options : {}; | ||
const capture = | ||
(typeof options === 'boolean' ? options : optionsObj.capture) || false; | ||
const once = optionsObj.once || false; | ||
const passive = optionsObj.passive || false; | ||
const signal = null; // TODO: implement signal/AbortSignal | ||
|
||
const eventListeners: InternalEventListeners = this._eventListeners || {}; | ||
if (this._eventListeners === null) { | ||
this._eventListeners = eventListeners; | ||
} | ||
|
||
const namedEventListeners = eventListeners[eventType] || []; | ||
if (eventListeners[eventType] == null) { | ||
eventListeners[eventType] = namedEventListeners; | ||
} | ||
|
||
namedEventListeners.push({ | ||
listener: listener, | ||
invalidated: false, | ||
options: { | ||
capture: capture, | ||
once: once, | ||
passive: passive, | ||
signal: signal, | ||
}, | ||
}); | ||
} | ||
|
||
// See https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener | ||
removeEventListener_unstable( | ||
eventType: string, | ||
listener: EventListener, | ||
options: EventListenerRemoveOptions | boolean, | ||
) { | ||
// eventType and listener must be referentially equal to be removed from the listeners | ||
// data structure, but in "options" we only check the `capture` flag, according to spec. | ||
// That means if you add the same function as a listener with capture set to true and false, | ||
// you must also call removeEventListener twice with capture set to true/false. | ||
const optionsObj = | ||
typeof options === 'object' && options !== null ? options : {}; | ||
const capture = | ||
(typeof options === 'boolean' ? options : optionsObj.capture) || false; | ||
|
||
// If there are no event listeners or named event listeners, we can bail early - our | ||
// job is already done. | ||
const eventListeners = this._eventListeners; | ||
if (!eventListeners) { | ||
return; | ||
} | ||
const namedEventListeners = eventListeners[eventType]; | ||
if (!namedEventListeners) { | ||
return; | ||
} | ||
|
||
eventListeners[eventType] = namedEventListeners.filter(listenerObj => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this allocation unavoidable? remove/add resubscriptions can be a hot path. It would be nice if removing and adding was very cheap. |
||
return !( | ||
listenerObj.listener === listener && | ||
listenerObj.options.capture === capture | ||
); | ||
}); | ||
} | ||
} | ||
|
||
// eslint-disable-next-line no-unused-expressions | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,11 +10,15 @@ | |
import type {AnyNativeEvent} from './legacy-events/PluginModuleType'; | ||
import type {TopLevelType} from './legacy-events/TopLevelEventTypes'; | ||
import SyntheticEvent from './legacy-events/SyntheticEvent'; | ||
import type {PropagationPhases} from './legacy-events/PropagationPhases'; | ||
|
||
// Module provided by RN: | ||
import {ReactNativeViewConfigRegistry} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; | ||
import { | ||
CustomEvent, | ||
ReactNativeViewConfigRegistry, | ||
} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; | ||
import accumulateInto from './legacy-events/accumulateInto'; | ||
import getListener from './ReactNativeGetListener'; | ||
import getListeners from './ReactNativeGetListeners'; | ||
import forEachAccumulated from './legacy-events/forEachAccumulated'; | ||
import {HostComponent} from 'react-reconciler/src/ReactWorkTags'; | ||
|
||
|
@@ -26,10 +30,15 @@ const { | |
// Start of inline: the below functions were inlined from | ||
// EventPropagator.js, as they deviated from ReactDOM's newer | ||
// implementations. | ||
function listenerAtPhase(inst, event, propagationPhase: PropagationPhases) { | ||
function listenersAtPhase( | ||
inst, | ||
event, | ||
propagationPhase: PropagationPhases, | ||
isCustomEvent: boolean, | ||
) { | ||
const registrationName = | ||
event.dispatchConfig.phasedRegistrationNames[propagationPhase]; | ||
return getListener(inst, registrationName); | ||
return getListeners(inst, registrationName, propagationPhase, isCustomEvent); | ||
} | ||
|
||
function accumulateDirectionalDispatches(inst, phase, event) { | ||
|
@@ -38,13 +47,21 @@ function accumulateDirectionalDispatches(inst, phase, event) { | |
console.error('Dispatching inst must not be null'); | ||
} | ||
} | ||
const listener = listenerAtPhase(inst, event, phase); | ||
if (listener) { | ||
const listeners = listenersAtPhase( | ||
inst, | ||
event, | ||
phase, | ||
event instanceof CustomEvent, | ||
); | ||
if (listeners && listeners.length > 0) { | ||
event._dispatchListeners = accumulateInto( | ||
event._dispatchListeners, | ||
listener, | ||
listeners, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The vast majority case is going to be one listener. It feels a bit wrong to me that we're now paying the runtime cost assuming there's more than one. I.e. always having an array. Maybe we can simplify the code. E.g. I would suggest that if we're doing this, let's get rid of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tried to address this in the next PR by returning T | Array | null from getEventListeners and avoid allocations more aggressively |
||
); | ||
event._dispatchInstances = accumulateInto(event._dispatchInstances, inst); | ||
const insts = listeners.map(() => { | ||
return inst; | ||
}); | ||
event._dispatchInstances = accumulateInto(event._dispatchInstances, insts); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here, too, we create an intermediate array via |
||
} | ||
} | ||
|
||
|
@@ -66,7 +83,12 @@ function getParent(inst) { | |
/** | ||
* Simulates the traversal of a two-phase, capture/bubble event dispatch. | ||
*/ | ||
export function traverseTwoPhase(inst: Object, fn: Function, arg: Function) { | ||
export function traverseTwoPhase( | ||
inst: Object, | ||
fn: Function, | ||
arg: Function, | ||
bubbles: boolean, | ||
) { | ||
const path = []; | ||
while (inst) { | ||
path.push(inst); | ||
|
@@ -78,12 +100,28 @@ export function traverseTwoPhase(inst: Object, fn: Function, arg: Function) { | |
} | ||
for (i = 0; i < path.length; i++) { | ||
fn(path[i], 'bubbled', arg); | ||
// It's possible this is false for custom events. | ||
if (!bubbles) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this related to adding support for capturable-but-not-bubbling events? I had concerns with this that I described in the post about events and composition. I'd like to make sure we have addressed these concerns before adding/exposing that feature. I'm not sure if this PR is orthogonal to what @lunaleaps tried originally, or whether it includes a similar feature. If it includes that feature, then I’d like to make sure we have @sebmarkbage’s stamp on that. Luna has the context on my concerns. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe this PR is mostly orthogonal. This is for capturable-but-not-bubbling CustomEvents since those are legal under the W3C spec. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh this change is similar to what I was trying to add to support pointer events that skipBubbling -- maybe we can split this out to the separate PR https://github.com/facebook/react/pull/23366/files#diff-64c0fc0cf584da861da7ea6b3b66f74bebbc8fa4857be0c4021f186f812219ddR96 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm submitting a new PR that just adds addEventListener/removeEventListener, so we don't need any code related to CustomEvent for now. |
||
break; | ||
} | ||
} | ||
} | ||
|
||
function accumulateTwoPhaseDispatchesSingle(event) { | ||
if (event && event.dispatchConfig.phasedRegistrationNames) { | ||
traverseTwoPhase(event._targetInst, accumulateDirectionalDispatches, event); | ||
// bubbles is only set on the dispatchConfig for custom events. | ||
// The `event` param here at this point is a SyntheticEvent, not an Event or CustomEvent. | ||
const bubbles = | ||
event.dispatchConfig.isCustomEvent === true | ||
? !!event.dispatchConfig.bubbles | ||
: true; | ||
|
||
traverseTwoPhase( | ||
event._targetInst, | ||
accumulateDirectionalDispatches, | ||
event, | ||
bubbles, | ||
); | ||
} | ||
} | ||
|
||
|
@@ -103,13 +141,27 @@ function accumulateDispatches( | |
): void { | ||
if (inst && event && event.dispatchConfig.registrationName) { | ||
const registrationName = event.dispatchConfig.registrationName; | ||
const listener = getListener(inst, registrationName); | ||
if (listener) { | ||
// Since we "do not look for phased registration names", that | ||
// should be the same as "bubbled" here, for all intents and purposes...? | ||
const listeners = getListeners( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same comments about |
||
inst, | ||
registrationName, | ||
'bubbled', | ||
!!event.dispatchConfig.isCustomEvent, | ||
); | ||
if (listeners) { | ||
event._dispatchListeners = accumulateInto( | ||
event._dispatchListeners, | ||
listener, | ||
listeners, | ||
); | ||
// an inst for every listener | ||
const insts = listeners.map(() => { | ||
return inst; | ||
}); | ||
event._dispatchInstances = accumulateInto( | ||
event._dispatchInstances, | ||
insts, | ||
); | ||
event._dispatchInstances = accumulateInto(event._dispatchInstances, inst); | ||
} | ||
} | ||
} | ||
|
@@ -130,7 +182,6 @@ function accumulateDirectDispatches(events: ?(Array<Object> | Object)) { | |
} | ||
|
||
// End of inline | ||
type PropagationPhases = 'bubbled' | 'captured'; | ||
|
||
const ReactNativeBridgeEventPlugin = { | ||
eventTypes: {}, | ||
|
@@ -147,23 +198,53 @@ const ReactNativeBridgeEventPlugin = { | |
} | ||
const bubbleDispatchConfig = customBubblingEventTypes[topLevelType]; | ||
const directDispatchConfig = customDirectEventTypes[topLevelType]; | ||
let customEventConfig = null; | ||
if (nativeEvent instanceof CustomEvent) { | ||
// $FlowFixMe | ||
if (topLevelType.indexOf('on') !== 0) { | ||
throw new Error('Custom event name must start with "on"'); | ||
} | ||
nativeEvent.isTrusted = false; | ||
// For now, this custom event name should technically not be used - | ||
// CustomEvents emitted in the system do not result in calling prop handlers. | ||
customEventConfig = { | ||
registrationName: topLevelType, | ||
isCustomEvent: true, | ||
bubbles: nativeEvent.bubbles, | ||
phasedRegistrationNames: { | ||
bubbled: topLevelType, | ||
// Unlike with the props-based handlers, capture events are registered | ||
// to the HostComponent event emitter with the same name but a flag indicating | ||
// that the handler is for the capture phase. | ||
captured: topLevelType, | ||
}, | ||
}; | ||
} | ||
|
||
if (!bubbleDispatchConfig && !directDispatchConfig) { | ||
if (!bubbleDispatchConfig && !directDispatchConfig && !customEventConfig) { | ||
throw new Error( | ||
// $FlowFixMe - Flow doesn't like this string coercion because DOMTopLevelEventType is opaque | ||
`Unsupported top level event type "${topLevelType}" dispatched`, | ||
); | ||
} | ||
|
||
const event = SyntheticEvent.getPooled( | ||
bubbleDispatchConfig || directDispatchConfig, | ||
bubbleDispatchConfig || directDispatchConfig || customEventConfig, | ||
targetInst, | ||
nativeEvent, | ||
nativeEventTarget, | ||
); | ||
if (bubbleDispatchConfig) { | ||
if (bubbleDispatchConfig || customEventConfig) { | ||
// All CustomEvents go through two-phase dispatching, even if they | ||
// are non-bubbling events, which is why we put the `bubbles` param | ||
// in the config for CustomEvents only. | ||
// CustomEvents are not emitted to prop handler functions ever. | ||
// Native two-phase events will be emitted to prop handler functions | ||
// and to HostComponent event listeners. | ||
accumulateTwoPhaseDispatches(event); | ||
} else if (directDispatchConfig) { | ||
// Direct dispatched events do not go to HostComponent EventEmitters, | ||
// they *only* go to the prop function handlers. | ||
accumulateDirectDispatches(event); | ||
} else { | ||
return null; | ||
|
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.
Even if we initialize it lazily, I'm not sure I feel great about adding a new field to every single host instance, even if it's set to null. We're trying to minimize memory usage. And this is doing that for a (relatively uncommon) feature in the tree. I wonder if there's any way we could only pay the cost for the components using it. E.g. some sort of a Map outside. I guess it would have to be a WeakMap. I think we need to get @sebmarkbage opinion on this.
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.
One extra field is probably not the biggest deal here. The big deal is that this object exists at all and that methods use virtual dispatch and that all methods must always be loaded instead of lazily.
The goal was to get rid of it but if the goal is to preserve DOM-like looks, then maybe the whole object can at least be made lazy only if there are refs on it.
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.
By "this object" do you mean the ReactFabricHostComponent object? Can you describe what the alternative would be - just using a raw JSI-bridged native object? I was not aware of those plans. Worth discussing that more for sure if we want to move further in that direction, I'd like to hear pros/cons and it'd be good to have an idea of what the cost of this class and fields are
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 you mean something like - ReactFabricHostComponent is not instantiated for a given ShadowNode until/unless it is requested via the
ref
prop? That is a pretty interesting optimization that I would be happy to drive (in a few months - I'm going on leave soon) - I would be happy with that, hopefully we agree that that is outside of the scope of this PR?