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

Fix change events for custom elements #22938

Merged
merged 26 commits into from
Jan 19, 2022
Merged

Conversation

josepharhar
Copy link
Contributor

@josepharhar josepharhar commented Dec 11, 2021

Summary

This patch makes onChange handlers listen to change events for custom elements. There was some odd behavior with the onChange handler and change events, but this patch will make it work for custom elements.

This fixes #22888 and I manually tested that it fixes Dan's example.

How did you test this change?

I tested it with the broken example and added tests in DOMPropertyOperations-test.js.

@sizebot
Copy link

sizebot commented Dec 11, 2021

Comparing: c09596c...93c9db1

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 = 129.52 kB 129.52 kB = 41.53 kB 41.53 kB
oss-experimental/react-dom/cjs/react-dom.production.min.js +0.03% 134.65 kB 134.69 kB +0.07% 43.02 kB 43.04 kB
facebook-www/ReactDOM-prod.classic.js = 427.76 kB 427.76 kB = 78.53 kB 78.53 kB
facebook-www/ReactDOM-prod.modern.js +0.06% 417.47 kB 417.74 kB +0.06% 77.06 kB 77.11 kB
facebook-www/ReactDOMForked-prod.classic.js = 427.76 kB 427.76 kB = 78.53 kB 78.53 kB

Significant size changes

Includes any change greater than 0.2%:

(No significant changes)

Generated by 🚫 dangerJS against 93c9db1

@sebmarkbage
Copy link
Collaborator

This would break the event order relative to other click events etc. Break stop propagation etc. Since the react ones are delegated.

That's partly why we wanted to keep special casing React events.

I suspect that the fix might be specific to onChange and that the rest would work well because you really shouldn't repurpose the built in names with custom events. Since they can bubble through.

We want to remove the synthetic event system all together and maybe it would make sense to put that in the same release. It's a much bigger change tho.

@josepharhar
Copy link
Contributor Author

This would break the event order relative to other click events etc. Break stop propagation etc. Since the react ones are delegated.

That's partly why we wanted to keep special casing React events.

I suspect that the fix might be specific to onChange and that the rest would work well because you really shouldn't repurpose the built in names with custom events. Since they can bubble through.

We want to remove the synthetic event system all together and maybe it would make sense to put that in the same release. It's a much bigger change tho.

Ok, I agree with your points, thanks! I'll try changing react's event system for custom elements then.

Here's a more detailed example: https://codesandbox.io/s/sleepy-field-z7g83?file=/src/App.js
It highlights that there are two problems with the onChange example that Dan made:

  1. React's event system only looks at input events for the onInput handler, and the onChange handler doesn't seem to do anything at all.
  2. React's event system only looks at bubbling events, at least for input events.

If you have any advice for where to look to fix these things, it would be greatly appreciated! I couldn't even figure out why input and change are handled differently while debugging last week.

@gaearon
Copy link
Collaborator

gaearon commented Dec 12, 2021

If you have any advice for where to look to fix these things, it would be greatly appreciated! I couldn't even figure out why input and change are handled differently while debugging last week.

Do you want to look at ChangeEventPlugin? Only events “registered” by SimpleEventPlugin work “as is”. The rest (like onChange) have special behavior described in the plugins.

@josepharhar josepharhar changed the title Bypass react event system for custom elements Fix change events for custom elements Dec 25, 2021
@josepharhar
Copy link
Contributor Author

Ok, instead of bypassing the react event system, I made a small change to SimpleEventPlugin to make onChange work for custom elements. I updated this PR description, and I tested that it works with Dan's example.

Do you want to look at ChangeEventPlugin? Only events “registered” by SimpleEventPlugin work “as is”. The rest (like onChange) have special behavior described in the plugins.

I tried messing with ChangeEventPlugin but I couldn't get it to work quite right, and it was more complex, and there is a long comment here suggesting that all the logic should be in SimpleEventPlugin anyway...

@gaearon
Copy link
Collaborator

gaearon commented Jan 12, 2022

Is change event the only special event? I would expect to have special events for each plugin. Why does this change (pardon the pun) only affect change?

@josepharhar
Copy link
Contributor Author

After coming back to this after a while, I found that I could just modify ChangeEventPlugin to fix this despite my previous comment saying that I couldn't get it to work 😅 I pushed a commit to do this.

Is change event the only special event? I would expect to have special events for each plugin. Why does this change (pardon the pun) only affect change?

There is code in ChangeEventPlugin to only call onChange handlers for select and input elements:

function shouldUseChangeEvent(elem) {
const nodeName = elem.nodeName && elem.nodeName.toLowerCase();
return (
nodeName === 'select' ||
(nodeName === 'input' && (elem: any).type === 'file')
);
}

As far as I know, the other plugins don't have this constraint.

if (
enableCustomElementPropertySupport &&
targetInst &&
isCustomComponent(targetInst.elementType, targetInst.pendingProps)
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 should use memoizedProps instead which represent the ones that were committed. pendingProps represent last work in progress which could've been interrupted.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, I'm able to comment out this whole line and the tests still pass. I think it's because there is no test for the is="...." case which isCustomComponent is trying to catch.

Copy link
Collaborator

@gaearon gaearon Jan 14, 2022

Choose a reason for hiding this comment

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

Hmm. I understand we'd want <div is="my-custom-element"> to work with change event.

But if you do <input is="my-custom-element">, which branch of logic should we use? Should it use the "raw" behavior or the polyfill which we normally use for inputs?

Copy link
Collaborator

Choose a reason for hiding this comment

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

@sebmarkbage do you have an opinion on this? seems like it's a bit simpler to turn off polyfilling when you do <input is="my-thing"> but that would mean that adding is changes semantics of your onChange handler. maybe that's ok because you decided to "go manual"? especially in the light of onChange->onInput future change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, I'm able to comment out this whole line and the tests still pass. I think it's because there is no test for the is="...." case which isCustomComponent is trying to catch.

On my machine, if I comment out just the && isCustomComponent(...), 7 tests fail from various test files. If I undo the entire change here, 2 of the tests I added fail.

Hmm. I understand we'd want <div is="my-custom-element"> to work with change event.

Yes, <div is='my-custom-element'> should get this behavior as well.
I could copy all of the tests I added and use is instead of <my-custom-element /> to show that I used isCustomComponent, but you could say the same thing about all of the many custom element tests I added...

But if you do , which branch of logic should we use? Should it use the "raw" behavior or the polyfill which we normally use for inputs?

Based on what I read in DOMPluginEventSystem, ChangeEventPlugin is a "polyfill", right? The code I added is in ChangeEventPlugin, which means that <input> and custom elements should now be treated the same with regards to this behavior, right...?

I think this should use memoizedProps instead which represent the ones that were committed. pendingProps represent last work in progress which could've been interrupted.

Thanks, I was wondering which I should use! I have changed it to memoizedProps.

Copy link
Collaborator

@gaearon gaearon Jan 15, 2022

Choose a reason for hiding this comment

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

Based on what I read in DOMPluginEventSystem, ChangeEventPlugin is a "polyfill", right? The code I added is in ChangeEventPlugin, which means that and custom elements should now be treated the same with regards to this behavior, right...?

I meant the code of the polyfill below (look how this function is used). In the polyfill itself, we decide between using one of the events under the hood: change, input, or click. We decide that based on the type of the element. The code you added makes us always use change event for custom elements. That's good for <custom-element> or <div is="custom-element">. What I'm not sure about is <input is="custom-element">.

Should <input is="custom-element"> map onChange to the "raw" change event (which means it would work differently from <input> which in React maps onChange to input)? Or should it map onChange to the input/click events to mirror how <input> works?

In the latter case, it would mean that some custom elements still get special polyfill behavior that isn't 1:1 to the event names. That seems like it would be confusing. But if we go with former case (raw events), adding is to an existing input and having its onChange completely break (no longer called on every keystroke) also seems very confusing? Which is the behavior we'd get if it kept it raw like in this PR.

Using raw events for <input is="custom-element"> feels like simpler behavior to me but I'm not sure. There's also a potential intent to change it for <input> in distant future altogether: #9657.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@josepharhar Taking a step back, we should look at how controlled inputs generally work with <input is="my-custom-element">. Like how are you supposed to write a controlled input? One of the features of controlled inputs is that <input value=""> with no onChange handler won't allow typing. The same should be true for <input value="" is="my-custom-element">. Is that the case? If it already works, is it necessary to add onChange (like normal React) or onInput (closer to browser behavior) to make it respond to typing? That's what we need to decide on.

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 talked a bit with some LitElement folks about this, and they pointed out that Safari doesn't support the is attribute at all and that they advise against using it: https://bugs.webkit.org/show_bug.cgi?id=182671

I think that it would make the most sense to continue doing all of react's current behavior for form controls when they have the is attribute. Besides, the element and its shadowdom is still the same in this case when using is.

I pushed a commit to do this with tests.

@sebmarkbage
Copy link
Collaborator

sebmarkbage commented Jan 15, 2022

We should have some tests for these cases too:

<div onChange={} onInput={}>
  <my-custom-element />
  <input />
  <input is=“my-custom-element />
</div>

I.e. what is the bubbling expectations.

Also what about <my-custom-element onChange={…}><div /></my-custom-element>?

Where the target is the child div.

customInput.dispatchEvent(new Event('click', {bubbles: true}));

expect(customOnInputHandler).toHaveBeenCalledTimes(
regularOnInputHandler.mock.calls.length,
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is kind of fragile. (What if we change some heuristic and all of these are 0.) Can you make the test a bit more specific? Also ideally you'd verify that the handler runs as early as possible, and not at the end of the function. So that if there's a timing difference, it is visible.

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 added expectations after each dispatchEvent and put specific numbers in

@josepharhar
Copy link
Contributor Author

We should have some tests for these cases too:

<div onChange={…} onInput={…}>
  <my-custom-element />
  <input />
  <input is=“my-custom-element” />
</div>

I.e. what is the bubbling expectations.

Thanks for pointing this out!
I pushed a test for this.

Also what about <my-custom-element onChange={…}><div /></my-custom-element>?

Where the target is the child div.

I added a test for this, and unfortunately it looks like change/onChange doesn't work, but input/onInput does work... I think I could fix this by walking all the way through the event path of the event and calling createAndAccumulateChangeEvent on each node, or go back to the SimpleEventPlugin approach, but I'm not sure what to do now...


regularInput.dispatchEvent(new Event('input', {bubbles: true}));
expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
Copy link
Collaborator

Choose a reason for hiding this comment

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

It looks like onChange never fires in any of these? Then it seems like this isn't testing the thing we care about. Since we care that input causes onChange.

Most likely what's missing is the actual change in the value. (And I think we also have to force React to "see" it with a hack). See how existing input tests do it:

setUntrackedValue.call(instance.a, 'giraffe');
// This must use the native event dispatching. If we simulate, we will
// bypass the lazy event attachment system so we won't actually test this.
dispatchEventOnNode(instance.a, 'input');

Copy link
Collaborator

@gaearon gaearon Jan 19, 2022

Choose a reason for hiding this comment

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

I've pushed a few commits that address this and make the tests have a more realistic sequence of events.

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.

need to fix tests

@gaearon gaearon merged commit 05a55a4 into facebook:main Jan 19, 2022
@josepharhar
Copy link
Contributor Author

Thanks for finishing this up!

zhengjitf pushed a commit to zhengjitf/react that referenced this pull request Apr 15, 2022
* Bypass react event system for custom elements

* Going to try fixing react event system instead

* finally got it to call onChange, but perhaps too many times

* update test

* Removed ReactDOMComponent changes, now works but still doubles for bubbles

* Maybe i should only support bubbling events

* removed some old stuff

* cleaned up changeeventplugin stuff

* prettier, lint

* removed changeeventplugin stuff

* remove unneeded gate for onInput test

* Go back to using ChangeEventPlugin

* Add input+change test

* lint

* Move logic to shouldUseChangeEvent

* use memoizedProps instead of pendingProps

* Run form control behavior before custom element behavior

* add bubbling test

* forgot to append container to body

* add child event target test

* expand <input is=...> test expectations

* Make tests more realistic

* Add extra test

* Add missing gating

* Actually fix gating

Co-authored-by: Dan Abramov <dan.abramov@me.com>
nevilm-lt pushed a commit to nevilm-lt/react that referenced this pull request Apr 22, 2022
* Bypass react event system for custom elements

* Going to try fixing react event system instead

* finally got it to call onChange, but perhaps too many times

* update test

* Removed ReactDOMComponent changes, now works but still doubles for bubbles

* Maybe i should only support bubbling events

* removed some old stuff

* cleaned up changeeventplugin stuff

* prettier, lint

* removed changeeventplugin stuff

* remove unneeded gate for onInput test

* Go back to using ChangeEventPlugin

* Add input+change test

* lint

* Move logic to shouldUseChangeEvent

* use memoizedProps instead of pendingProps

* Run form control behavior before custom element behavior

* add bubbling test

* forgot to append container to body

* add child event target test

* expand <input is=...> test expectations

* Make tests more realistic

* Add extra test

* Add missing gating

* Actually fix gating

Co-authored-by: Dan Abramov <dan.abramov@me.com>
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.

Bug: onChange event handlers don't work on custom elements
5 participants