Skip to content
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

[WIP] React Native: Turn HostComponent into an EventEmitter #23278

Closed

Conversation

JoshuaGross
Copy link
Contributor

@JoshuaGross JoshuaGross commented Feb 11, 2022

Summary

The primary goal is to make the Fabric HostComponent into a W3C spec-compliant EventEmitter.

TODO: filling this out more in an hour or so

Open questions for later:

  1. The most easiest way to do this is to implement the EventEmitter only for Fabric. Do we care about making this backwards-compatible with non-Fabric?
  2. Where should a generic polyfilled (?) Event class live for React Native? A: this will live in React Native, in this repo we'll assume Event exists on the global scope
  3. Need to make sure that propagation cancelation and similar imperative API calls on Event are somehow propagated to the SyntheticEvent
  4. Need to make sure that a raw Event (not SyntheticEvent) is passed to the event handler. However, this could mean that, for native events passed to an event listener, they either (1) need to be passed the SyntheticEvent or (2) won't be able to stop bubbling, propagation, etc, on native events only.

How did you test this change?

tbd

@sizebot
Copy link

sizebot commented Feb 11, 2022

Comparing: 9b5e051...a994a3a

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.min.js = 130.35 kB 130.35 kB = 41.82 kB 41.82 kB
oss-experimental/react-dom/cjs/react-dom.production.min.js = 135.53 kB 135.53 kB = 43.35 kB 43.35 kB
facebook-www/ReactDOM-prod.classic.js = 431.14 kB 431.14 kB = 79.11 kB 79.11 kB
facebook-www/ReactDOM-prod.modern.js = 421.08 kB 421.08 kB = 77.68 kB 77.68 kB
facebook-www/ReactDOMForked-prod.classic.js = 431.14 kB 431.14 kB = 79.11 kB 79.11 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
react-native/shims/ReactNativeTypes.js +1.84% 7.16 kB 7.29 kB +3.25% 2.00 kB 2.07 kB
react-native/implementations/ReactFabric-prod.js +1.69% 274.03 kB 278.66 kB +1.99% 49.27 kB 50.25 kB
react-native/implementations/ReactFabric-prod.fb.js +1.62% 285.51 kB 290.14 kB +1.94% 51.47 kB 52.47 kB
react-native/implementations/ReactFabric-profiling.js +1.58% 293.04 kB 297.67 kB +1.94% 52.43 kB 53.45 kB
react-native/implementations/ReactFabric-profiling.fb.js +1.48% 312.54 kB 317.17 kB +1.87% 55.73 kB 56.77 kB
react-native/implementations/ReactFabric-dev.js +1.40% 723.91 kB 734.03 kB +2.02% 157.74 kB 160.93 kB
react-native/implementations/ReactFabric-dev.fb.js +1.32% 766.17 kB 776.29 kB +1.93% 165.47 kB 168.67 kB
react-native/implementations/ReactNativeRenderer-prod.js +1.01% 282.67 kB 285.51 kB +1.17% 50.87 kB 51.46 kB
react-native/implementations/ReactNativeRenderer-prod.fb.js +0.98% 289.48 kB 292.32 kB +1.12% 52.27 kB 52.86 kB
react-native/implementations/ReactNativeRenderer-profiling.js +0.94% 301.72 kB 304.56 kB +1.08% 54.01 kB 54.60 kB
react-native/implementations/ReactNativeRenderer-profiling.fb.js +0.90% 316.40 kB 319.24 kB +1.05% 56.51 kB 57.10 kB
react-native/implementations/ReactNativeRenderer-dev.js +0.87% 736.33 kB 742.75 kB +1.43% 160.47 kB 162.76 kB
react-native/implementations/ReactNativeRenderer-dev.fb.js +0.83% 776.73 kB 783.15 kB +1.36% 167.81 kB 170.09 kB

Generated by 🚫 dangerJS against a994a3a

}
}
for (var listenerObj of toRemove) {
stateNode.canonical.removeEventListener_unstable(registrationName, listenerObj.listener, listenerObj.capture);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't have the context on the background of the APIs being added in this PR. But one thing that jumps out to me is that it seems like a hazard that calling getListener has a side effect of modifying the listeners. This seems very easy to accidentally mess up from the caller side because it looks like a pure 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.

Do you have a suggestion here? I agree - but currently with the way this API is used, these listeners are guaranteed to be called in the same frame; and if the semantic is "once", then we need to remove the listener or somehow mark it as "used". Keeping it around forever with a dirty flag doesn't seem good either for memory reasons.

The "once" flag is a (w3c) standard flag, so it's necessary and useful to make sure it works properly.

Thanks for the feedback.

Copy link
Collaborator

@gaearon gaearon Feb 11, 2022

Choose a reason for hiding this comment

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

I think my instinct would be to wrap the listeners that have { once: true } with code that removes the listener imperatively when it actually fires. But make sure removeEventListener still works when the original function is passed. Then the special case is rare and the getListener function stays pure.

Semantic question: if propagation was prevented by a handler above, does the parent handler with {once: true} get removed or not? Seems like this would affect how we implement it. getListener getting called does not guarantee the listener itself will get called.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this shows that in the DOM, the { once: true } parent listener does not get removed if the child stopped propagation: https://codesandbox.io/s/serene-minsky-380ki?file=/src/index.js

This means that getListener() is too early to remove the once listener. We can't do this until we're actually firing listeners.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Very fair and great feedback, I can make that change (don't remove until it's actually executed).

}

return listener;
// We need to call at least 2 event handlers
return function () {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Seems a bit concerning we're creating an extra function per listener as we're accumulating them across the tree.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this was easier for prototyping but I think I need to change the signature of this function to return an array of Functions.

@gaearon
Copy link
Collaborator

gaearon commented Feb 11, 2022

Where can I read about the background for these changes? I'm curious if we're adding something to each instance that we expect to be used widely, or if it's meant as a compat layer but we expect that most apps will only have a few calls to these. If it's mostly for compat, it might be beneficial to avoid allocations until it's used (e.g. begin with event listeners set to null instead of empty object, skip the getListener new logic if the array is null, etc). The concern being that we might be adding a bunch of code to a very hot path for a case that might be normally rare.

@JoshuaGross
Copy link
Contributor Author

@gaearon There's no central design doc yet, we've been having some discussions internally. tl;dr is that we're trying to make HostComponent act more like a DOM node and in particular adhere to the Event interface (see links to spec in code).

I think you're right about the optimization of setting listeners to null initially. That makes sense and I can easily do that.

Copy link
Collaborator

@gaearon gaearon left a comment

Choose a reason for hiding this comment

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

I don't feel comfortable accepting it without a look from @sebmarkbage. I wrote a few comments for the places that I'm not sure about. Hope this is helpful!

@@ -122,6 +145,7 @@ class ReactFabricHostComponent {
this.viewConfig = viewConfig;
this.currentProps = props;
this._internalInstanceHandle = internalInstanceHandle;
this._eventListeners = null;
Copy link
Collaborator

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.

Copy link
Collaborator

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The big deal is that this object exists at all ... The goal was to get rid of it

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the whole object can at least be made lazy only if there are refs on it

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?

// * 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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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 viewConfig and currentProps and actual "public" APIs. Maybe we should've made a separate "public instance" so that private stuff isn't exposed to refs. But I understand this follows the existing precedent.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

@necolas necolas Feb 28, 2022

Choose a reason for hiding this comment

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

this direction seems like a reversal of that strategy

The other option was to do addEventListener(hostElement, ...)

return;
}

eventListeners[eventType] = namedEventListeners.filter(listenerObj => {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

event._dispatchListeners = accumulateInto(
event._dispatchListeners,
listener,
listeners,
Copy link
Collaborator

@gaearon gaearon Feb 25, 2022

Choose a reason for hiding this comment

The 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. accumulateInto here is a helper that tries to add T | Array<T> on the right side to T | Array<T> on the left side. But now our right side is always an array. So the isArray check inside of accumulateInto is pointless. And creating an intermediate array only to stuff it back into another array also seems pointless.

I would suggest that if we're doing this, let's get rid of accumulateInto altogether. Instead of listenersAtPhase that always allocates, you could have something like pushListenersAtPhase which will lazily create event._dispatchListeners if needed, and then push into it. No accumulateInto, no intermediate checks, no extra arrays. It already has the access to event, anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

const insts = listeners.map(() => {
return inst;
});
event._dispatchInstances = accumulateInto(event._dispatchInstances, insts);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Here, too, we create an intermediate array via map only to copy it into another array. Let's rewrite this as plainly as possible? If we do the refactoring I suggested earlier then we can push into event._dispatchInstances from pushListenersAtPhase (in the same place where we're pushing into event._dispatchListeners) and we won't need this map. You already have access to inst there, too.

@@ -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) {
Copy link
Collaborator

@gaearon gaearon Feb 25, 2022

Choose a reason for hiding this comment

The 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.

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 believe this PR is mostly orthogonal.

This is for capturable-but-not-bubbling CustomEvents since those are legal under the W3C spec.

Copy link
Contributor

@lunaleaps lunaleaps Feb 28, 2022

Choose a reason for hiding this comment

The 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

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'm submitting a new PR that just adds addEventListener/removeEventListener, so we don't need any code related to CustomEvent for now.

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(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same comments about getListeners => pushListeners or something to prevent unnecessary allocation and later unnecessary accumulateInto checks.

@@ -77,6 +77,11 @@ function SyntheticEvent(
this._dispatchListeners = null;
this._dispatchInstances = null;

// React Native Event polyfill - enable Event proxying calls to SyntheticEvent
if (nativeEvent.setSyntheticEvent) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure what this checks for, but I worry about potential deopts here. This is very hot code. If we're checking non-existent property, if I recall correctly it can trigger make it slower. I'm also not sure I understand the purpose of "binding" a native event object back to this one. For example, in DOM we don't need to do that. What's different?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We have two APIs we're trying to support now - the props-based one that takes a SyntheticEvent, and the W3C-compliant EventEmitter one that expects an Event/CustomEvent. If a CustomEvent is passed through it gets wrapped in SyntheticEvent by React, but then we actually want calls on one to impact the other, so we need some mechanism to keep them synced.

My approach is admittedly pretty brute-force. There might be a cleaner way to keep the two in sync.

// at runtime. We should instead inject the version number as part of the build
// process, and use the ReactVersions.js module as the single source of truth.
export default '17.0.3';
export default '18.0.0-rc.0-experimental-049a50b73-20220214';
Copy link
Collaborator

Choose a reason for hiding this comment

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

Need to undo this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants