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

Add custom element property support behind a flag #22184

Merged
merged 43 commits into from
Dec 8, 2021
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
a2f57e0
custom element props
josepharhar Jan 11, 2021
92f1e1c
custom element events
josepharhar Jan 8, 2021
52166d9
use function type for on*
josepharhar Aug 17, 2021
a5bb048
tests, htmlFor
josepharhar Aug 25, 2021
660e770
className
josepharhar Aug 25, 2021
a84a2e6
fix ReactDOMComponent-test
josepharhar Aug 26, 2021
db7e13d
started on adding feature flag
josepharhar Aug 26, 2021
74a7d9d
added feature flag to all feature flag files
josepharhar Aug 26, 2021
7bb6fa4
everything passes
josepharhar Aug 26, 2021
55a1e3c
tried to fix getPropertyInfo
josepharhar Aug 26, 2021
23d406b
used @gate and __experimental__
josepharhar Aug 27, 2021
8a2651b
remove flag gating for test which already passes
josepharhar Aug 27, 2021
ae33345
fix onClick test
josepharhar Aug 31, 2021
9bec8b1
add __EXPERIMENTAL__ to www flags, rename eventProxy
josepharhar Aug 31, 2021
af292bc
Add innerText and textContent to reservedProps
josepharhar Sep 14, 2021
1a093e5
Emit warning when assigning to read only properties in client
josepharhar Sep 28, 2021
9d6d1dd
Revert "Emit warning when assigning to read only properties in client"
josepharhar Sep 29, 2021
dc1e6c2
Emit warning when assigning to read only properties during hydration
josepharhar Sep 29, 2021
6fa57fb
yarn prettier-all
josepharhar Sep 29, 2021
333d3d7
Gate hydration warning test on flag
josepharhar Nov 4, 2021
632c96c
Merge with 2 months of upstream commits
josepharhar Nov 5, 2021
b26e31f
Fix gating in hydration warning test
josepharhar Nov 5, 2021
ed4f899
Fix assignment to boolean properties
josepharhar Nov 5, 2021
4da5c57
Replace _listeners with random suffix matching
josepharhar Nov 6, 2021
91acb79
Improve gating for hydration warning test
josepharhar Nov 6, 2021
3cf8e44
Add outerText and outerHTML to server warning properties
josepharhar Nov 18, 2021
1fe88e2
remove nameLower logic
josepharhar Nov 30, 2021
7e6dc19
fix capture event listener test
josepharhar Nov 30, 2021
7f67c45
Add coverage for changing custom event listeners
josepharhar Nov 30, 2021
97ea2b4
yarn prettier-all
josepharhar Nov 30, 2021
5d641c2
yarn lint --fix
josepharhar Nov 30, 2021
fead37f
replace getCustomElementEventHandlersFromNode with getFiberCurrentPro…
josepharhar Nov 30, 2021
77afc53
Remove previous value when adding event listener
josepharhar Dec 3, 2021
c198d82
flow, lint, prettier
josepharhar Dec 3, 2021
3b0d45b
Add dispatchEvent to make sure nothing crashes
josepharhar Dec 6, 2021
7509c6d
Add state change to reserved attribute tests
josepharhar Dec 6, 2021
a59042e
Add missing feature flag test gate
josepharhar Dec 6, 2021
39b142e
Reimplement SSR changes in ReactDOMServerFormatConfig
josepharhar Dec 7, 2021
1c86699
Test hydration for objects and functions
josepharhar Dec 7, 2021
b043bfb
add missing test gate
josepharhar Dec 7, 2021
37ccabe
remove extraneous comment
josepharhar Dec 7, 2021
8fcf649
Add attribute->property test
josepharhar Dec 7, 2021
4bd3b44
Merge with 4 weeks of upstream commits
josepharhar Dec 7, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions packages/react-dom/src/__tests__/DOMPropertyOperations-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,186 @@ describe('DOMPropertyOperations', () => {
// Regression test for https://github.com/facebook/react/issues/6119
expect(container.firstChild.hasAttribute('value')).toBe(false);
});

// @gate enableCustomElementPropertySupport
it('custom element custom events lowercase', () => {
const oncustomevent = jest.fn();
function Test() {
return <my-custom-element oncustomevent={oncustomevent} />;
}
const container = document.createElement('div');
ReactDOM.render(<Test />, container);
container
.querySelector('my-custom-element')
.dispatchEvent(new Event('customevent'));
expect(oncustomevent).toHaveBeenCalledTimes(1);
});

// @gate enableCustomElementPropertySupport
it('custom element custom events uppercase', () => {
const oncustomevent = jest.fn();
function Test() {
return <my-custom-element onCustomevent={oncustomevent} />;
}
const container = document.createElement('div');
ReactDOM.render(<Test />, container);
container
.querySelector('my-custom-element')
.dispatchEvent(new Event('Customevent'));
expect(oncustomevent).toHaveBeenCalledTimes(1);
});

// @gate enableCustomElementPropertySupport
it('custom element custom event with dash in name', () => {
const oncustomevent = jest.fn();
function Test() {
return <my-custom-element oncustom-event={oncustomevent} />;
}
const container = document.createElement('div');
ReactDOM.render(<Test />, container);
container
.querySelector('my-custom-element')
.dispatchEvent(new Event('custom-event'));
expect(oncustomevent).toHaveBeenCalledTimes(1);
});

// @gate enableCustomElementPropertySupport
it('custom element remove event handler', () => {
const oncustomevent = jest.fn();
function Test(props) {
return <my-custom-element oncustomevent={props.handler} />;
}

const container = document.createElement('div');
ReactDOM.render(<Test handler={oncustomevent} />, container);
const customElement = container.querySelector('my-custom-element');
customElement.dispatchEvent(new Event('customevent'));
expect(oncustomevent).toHaveBeenCalledTimes(1);

ReactDOM.render(<Test handler={false} />, container);
// Make sure that the second render didn't create a new element. We want
// to make sure removeEventListener actually gets called on the same element.
expect(customElement).toBe(customElement);
customElement.dispatchEvent(new Event('customevent'));
expect(oncustomevent).toHaveBeenCalledTimes(1);
});
josepharhar marked this conversation as resolved.
Show resolved Hide resolved

it('custom elements shouldnt have non-functions for on* attributes treated as event listeners', () => {
const container = document.createElement('div');
ReactDOM.render(
<my-custom-element
onstring={'hello'}
onobj={{hello: 'world'}}
onarray={['one', 'two']}
ontrue={true}
onfalse={false}
/>,
container,
);
const customElement = container.querySelector('my-custom-element');
expect(customElement.getAttribute('onstring')).toBe('hello');
expect(customElement.getAttribute('onobj')).toBe('[object Object]');
expect(customElement.getAttribute('onarray')).toBe('one,two');
expect(customElement.getAttribute('ontrue')).toBe('true');
expect(customElement.getAttribute('onfalse')).toBe('false');
josepharhar marked this conversation as resolved.
Show resolved Hide resolved
});

it('custom elements should still have onClick treated like regular elements', () => {
gaearon marked this conversation as resolved.
Show resolved Hide resolved
let syntheticClickEvent = null;
const syntheticEventHandler = jest.fn(
event => (syntheticClickEvent = event),
);
let nativeClickEvent = null;
const nativeEventHandler = jest.fn(event => (nativeClickEvent = event));
function Test() {
return <span onClick={syntheticEventHandler} />;
}

const container = document.createElement('div');
document.body.appendChild(container);
ReactDOM.render(<Test />, container);

const span = container.querySelector('span');
span.onclick = nativeEventHandler;
container.querySelector('span').click();

expect(nativeEventHandler).toHaveBeenCalledTimes(1);
expect(syntheticEventHandler).toHaveBeenCalledTimes(1);
expect(syntheticClickEvent.nativeEvent).toBe(nativeClickEvent);
});

// @gate enableCustomElementPropertySupport
it('custom elements should allow custom events with capture event listeners', () => {
const oncustomevent = jest.fn();
function Test() {
return <my-custom-element oncustomeventCapture={oncustomevent} />;
}
const container = document.createElement('div');
ReactDOM.render(<Test />, container);
container
.querySelector('my-custom-element')
.dispatchEvent(new Event('customevent', {bubbles: false}));
expect(oncustomevent).toHaveBeenCalledTimes(1);
});

it('innerHTML should not work on custom elements', () => {
const container = document.createElement('div');
ReactDOM.render(<my-custom-element innerHTML="foo" />, container);
const customElement = container.querySelector('my-custom-element');
expect(customElement.getAttribute('innerHTML')).toBe(null);
expect(customElement.hasChildNodes()).toBe(false);
});

// @gate enableCustomElementPropertySupport
it('innerText should not work on custom elements', () => {
const container = document.createElement('div');
ReactDOM.render(<my-custom-element innerText="foo" />, container);
const customElement = container.querySelector('my-custom-element');
expect(customElement.getAttribute('innerText')).toBe(null);
expect(customElement.hasChildNodes()).toBe(false);
josepharhar marked this conversation as resolved.
Show resolved Hide resolved
});

// @gate enableCustomElementPropertySupport
it('textContent should not work on custom elements', () => {
const container = document.createElement('div');
ReactDOM.render(<my-custom-element textContent="foo" />, container);
const customElement = container.querySelector('my-custom-element');
expect(customElement.getAttribute('textContent')).toBe(null);
expect(customElement.hasChildNodes()).toBe(false);
});

// @gate enableCustomElementPropertySupport
it('values should not be converted to booleans when assigning into custom elements', () => {
const container = document.createElement('div');
document.body.appendChild(container);
ReactDOM.render(<my-custom-element />, container);
const customElement = container.querySelector('my-custom-element');
customElement.foo = null;

// true => string
ReactDOM.render(<my-custom-element foo={true} />, container);
expect(customElement.foo).toBe(true);
ReactDOM.render(<my-custom-element foo="bar" />, container);
expect(customElement.foo).toBe('bar');

// false => string
ReactDOM.render(<my-custom-element foo={false} />, container);
expect(customElement.foo).toBe(false);
ReactDOM.render(<my-custom-element foo="bar" />, container);
expect(customElement.foo).toBe('bar');

// true => null
ReactDOM.render(<my-custom-element foo={true} />, container);
expect(customElement.foo).toBe(true);
ReactDOM.render(<my-custom-element foo={null} />, container);
expect(customElement.foo).toBe(null);

// false => null
ReactDOM.render(<my-custom-element foo={false} />, container);
expect(customElement.foo).toBe(false);
ReactDOM.render(<my-custom-element foo={null} />, container);
expect(customElement.foo).toBe(null);
});
});

describe('deleteValueForProperty', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
'use strict';

const ReactDOMServerIntegrationUtils = require('./utils/ReactDOMServerIntegrationTestUtils');
const ReactFeatureFlags = require('shared/ReactFeatureFlags');

let React;
let ReactDOM;
Expand All @@ -36,6 +37,7 @@ const {
resetModules,
itRenders,
clientCleanRender,
clientRenderOnServerString,
} = ReactDOMServerIntegrationUtils(initModules);

describe('ReactDOMServerIntegration', () => {
Expand Down Expand Up @@ -657,17 +659,28 @@ describe('ReactDOMServerIntegration', () => {
});

itRenders('className for custom elements', async render => {
const e = await render(<div is="custom-element" className="test" />, 0);
expect(e.getAttribute('className')).toBe('test');
if (ReactFeatureFlags.enableCustomElementPropertySupport) {
const e = await render(
<div is="custom-element" className="test" />,
render === clientRenderOnServerString ? 1 : 0,
);
expect(e.getAttribute('className')).toBe(null);
expect(e.getAttribute('class')).toBe('test');
} else {
const e = await render(<div is="custom-element" className="test" />, 0);
expect(e.getAttribute('className')).toBe('test');
}
});

itRenders('htmlFor attribute on custom elements', async render => {
const e = await render(<div is="custom-element" htmlFor="test" />);
expect(e.getAttribute('htmlFor')).toBe('test');
expect(e.getAttribute('for')).toBe(null);
});

itRenders('for attribute on custom elements', async render => {
const e = await render(<div is="custom-element" for="test" />);
expect(e.getAttribute('htmlFor')).toBe(null);
expect(e.getAttribute('for')).toBe('test');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,4 +531,29 @@ describe('ReactDOMServerHydration', () => {
'Warning: Did not expect server HTML to contain a <p> in <div>',
);
});

it('should warn when hydrating read-only properties', () => {
const readOnlyProperties = [
'offsetParent',
'offsetTop',
'offsetLeft',
'offsetWidth',
'offsetHeight',
'isContentEditable',
];
readOnlyProperties.forEach(readOnlyProperty => {
const props = {};
props[readOnlyProperty] = 'hello';
const jsx = React.createElement('my-custom-element', props);
const element = document.createElement('div');
element.innerHTML = ReactDOMServer.renderToString(jsx);
if (gate(flags => flags.enableCustomElementPropertySupport)) {
expect(() => ReactDOM.hydrate(jsx, element)).toErrorDev(
`Warning: Assignment to read-only property will result in a no-op: \`${readOnlyProperty}\``,
);
} else {
ReactDOM.hydrate(jsx, element);
}
});
});
});
53 changes: 53 additions & 0 deletions packages/react-dom/src/client/DOMPropertyOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import sanitizeURL from '../shared/sanitizeURL';
import {
disableJavaScriptURLs,
enableTrustedTypesIntegration,
enableCustomElementPropertySupport,
} from 'shared/ReactFeatureFlags';
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
import {getCustomElementEventHandlersFromNode} from './ReactDOMComponentTree';

import type {PropertyInfo} from '../shared/DOMProperty';

Expand Down Expand Up @@ -149,9 +151,52 @@ export function setValueForProperty(
if (shouldIgnoreAttribute(name, propertyInfo, isCustomComponentTag)) {
return;
}

if (
enableCustomElementPropertySupport &&
isCustomComponentTag &&
josepharhar marked this conversation as resolved.
Show resolved Hide resolved
name[0] === 'o' &&
name[1] === 'n'
) {
let eventName = name.replace(/Capture$/, '');
const useCapture = name !== eventName;
josepharhar marked this conversation as resolved.
Show resolved Hide resolved
const nameLower = eventName.toLowerCase();
if (nameLower in node) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Removing this if block doesn't fail any tests. What is it for? Do we need a test for coverage?

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 copied this over from these lines in preact: https://github.com/preactjs/preact/blob/dd1e281ddc6bf056aa6eaf5755b71112ef5011c5/src/diff/props.js#L88-L90

I suppose it's to make it so onClick listens for an event named click instead of Click, but only if the element has an onclick property. The comment suggests that this only matters for builtin elements, and since removing this line still makes the <my-custom-element onClick={...} /> test pass, it can be removed. I'll remove it.

eventName = nameLower;
}
eventName = eventName.slice(2);

const listenersObjName = eventName + (useCapture ? 'true' : 'false');
const listeners = getCustomElementEventHandlersFromNode(node);
const alreadyHadListener = listeners[listenersObjName];

if (typeof value === 'function' || alreadyHadListener) {
Copy link
Collaborator

@gaearon gaearon Nov 24, 2021

Choose a reason for hiding this comment

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

Hmm. So we do different things depending on the value type. This makes me a bit uneasy. I guess that's existing practice in Preact etc?

This makes me uneasy because props can always change midway. E.g. you get function on first render and something else on the second render. Do we have tests demonstrating what exactly happens when the type changes? The guarantee we try to preserve is that A -> B -> C -> D should have the same "end result" as just D, regardless of what A, B, C were. E.g. number -> string -> function -> number, or number -> function -> function -> string. If we can't guarantee that IMO we should at least warn. Or not support this.

(There might be some parallel in that we don't support "switching" between passing value={undefined} and value={str} to inputs. We warn in that case. At least we probably should make sure, whatever the behavior is, it probably shouldn't throw in just one of those cases.)

Thoughts?

Copy link
Collaborator

@gaearon gaearon Nov 24, 2021

Choose a reason for hiding this comment

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

Here's a concrete example.

This doesn't throw:

      const container = document.createElement('div');
      // ReactDOM.render(<Test handler={oncustomevent} />, container);
      ReactDOM.render(<Test handler="hello" />, container);
      const customElement = container.querySelector('my-custom-element');
      customElement.dispatchEvent(new Event('customevent'));

but this throws:

      const container = document.createElement('div');
      ReactDOM.render(<Test handler={oncustomevent} />, container);
      ReactDOM.render(<Test handler="hello" />, container);
      const customElement = container.querySelector('my-custom-element');
      customElement.dispatchEvent(new Event('customevent'));

TypeError: (0 , _ReactDOMComponentTree.getCustomElementEventHandlersFromNode)(...)[(e.type + "false")] is not a function

This doesn't seem like consistent behavior.

Some possible options:

  1. Fully consistent behavior, where the behavior matches the thing you set last. E.g. if you do event -> string, the behavior should be the same as if you set it to string right away. So no event attached, and nothing happens if it's dispatched. Essentially, treat passing non-function as a reason to remove the listener.
  2. Inconsistent behavior (e.g. whatever you pass first determines the behavior from that point on). But then passing something else should print a console.error. We still shouldn't have crashes like the above though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for pointing out this case! I like the fully consistent behavior option.

More on the function type behavior - Preact doesn't look at the type of the value passed in and unconditionally forwards it to addEventListener. Jason Miller endorsed this behavior by telling me that it supports the EventListener interface better because addEventListener can take objects in addition to just functions, and that nobody has ever complained about all properties with on being reserved.

I know that Sebastian seemed objected to reserving everything that starts with on (meaning you can't do one={'foo'} for example). I believe that this behavior of looking for functions strongly mitigates this issue, and I don't really see the EventListener interface being a big issue either. However, I don't exactly remember what Sebastian's last thoughts about this were, and I don't remember exactly when I decided to start looking for the function type. I did propose the behavior in this comment, and there wasn't really anyone disagreeing.

In the end, I think that we should go forward with this and do the Fully consistent behavior option. I'll plan on implementing it once I get through the rest of the comments in this PR.

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 some code here to implement the fully consistent option - when calling addEventListener, it will now try to figure out if we previously assigned the same prop as an attribute or property, in which case it will assign null into it.

listeners[listenersObjName] = value;
const proxy = useCapture ? fireEventProxyCapture : fireEventProxy;
if (value) {
if (!alreadyHadListener) {
node.addEventListener(eventName, proxy, useCapture);
}
} else {
node.removeEventListener(eventName, proxy, useCapture);
}
return;
}
}

if (shouldRemoveAttribute(name, value, propertyInfo, isCustomComponentTag)) {
value = null;
}

if (
enableCustomElementPropertySupport &&
isCustomComponentTag &&
josepharhar marked this conversation as resolved.
Show resolved Hide resolved
name in (node: any)
) {
(node: any)[name] = value;
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should have test coverage verifying that this does not get set in cases where we don't expect. E.g. if oncustomevent is not supposed to set the oncustomevent property even when it exists, we should have a test verifying this. That test should specify what happens when the value type changes midway (e.g. function -> string and back).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for pointing this out!
I added some tests with a variety of switching between nothing, string, and function.

return;
}

// If the prop isn't in the special list, treat it as a simple attribute.
if (isCustomComponentTag || propertyInfo === null) {
if (isAttributeNameSafe(name)) {
Expand Down Expand Up @@ -216,3 +261,11 @@ export function setValueForProperty(
}
}
}

function fireEventProxy(e: Event) {
josepharhar marked this conversation as resolved.
Show resolved Hide resolved
getCustomElementEventHandlersFromNode(this)[e.type + 'false'](e);
}

function fireEventProxyCapture(e: Event) {
getCustomElementEventHandlersFromNode(this)[e.type + 'true'](e);
}
30 changes: 27 additions & 3 deletions packages/react-dom/src/client/ReactDOMComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ import {validateProperties as validateARIAProperties} from '../shared/ReactDOMIn
import {validateProperties as validateInputProperties} from '../shared/ReactDOMNullInputValuePropHook';
import {validateProperties as validateUnknownProperties} from '../shared/ReactDOMUnknownPropertyHook';

import {enableTrustedTypesIntegration} from 'shared/ReactFeatureFlags';
import {
enableTrustedTypesIntegration,
enableCustomElementPropertySupport,
} from 'shared/ReactFeatureFlags';
import {
mediaEventTypes,
listenToNonDelegatedEvent,
Expand Down Expand Up @@ -998,7 +1001,10 @@ export function diffHydratedProperties(
) {
// Validate that the properties correspond to their expected values.
let serverValue;
const propertyInfo = getPropertyInfo(propKey);
const propertyInfo =
isCustomComponentTag && enableCustomElementPropertySupport
? null
: getPropertyInfo(propKey);
if (suppressHydrationWarning) {
// Don't bother comparing. We're ignoring all these warnings.
} else if (
Expand Down Expand Up @@ -1031,7 +1037,25 @@ export function diffHydratedProperties(
warnForPropDifference(propKey, serverValue, expectedStyle);
}
}
} else if (isCustomComponentTag) {
} else if (
enableCustomElementPropertySupport &&
isCustomComponentTag &&
(propKey === 'offsetParent' ||
propKey === 'offsetTop' ||
propKey === 'offsetLeft' ||
propKey === 'offsetWidth' ||
propKey === 'offsetHeight' ||
propKey === 'isContentEditable')
) {
josepharhar marked this conversation as resolved.
Show resolved Hide resolved
// $FlowFixMe - Should be inferred as not undefined.
extraAttributeNames.delete(propKey.toLowerCase());
if (__DEV__) {
josepharhar marked this conversation as resolved.
Show resolved Hide resolved
console.error(
'Assignment to read-only property will result in a no-op: `%s`',
propKey,
);
}
} else if (isCustomComponentTag && !enableCustomElementPropertySupport) {
// $FlowFixMe - Should be inferred as not undefined.
extraAttributeNames.delete(propKey.toLowerCase());
serverValue = getValueForAttribute(domElement, propKey, nextProp);
Expand Down
Loading