-
Notifications
You must be signed in to change notification settings - Fork 49.1k
Add custom element property support behind a flag #22184
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 10 commits
a2f57e0
92f1e1c
52166d9
a5bb048
660e770
a84a2e6
db7e13d
74a7d9d
7bb6fa4
55a1e3c
23d406b
8a2651b
ae33345
9bec8b1
af292bc
1a093e5
9d6d1dd
dc1e6c2
6fa57fb
333d3d7
632c96c
b26e31f
ed4f899
4da5c57
91acb79
3cf8e44
1fe88e2
7e6dc19
7f67c45
97ea2b4
5d641c2
fead37f
77afc53
c198d82
3b0d45b
7509c6d
a59042e
39b142e
1c86699
b043bfb
37ccabe
8fcf649
4bd3b44
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 |
---|---|---|
|
@@ -9,6 +9,7 @@ | |
|
||
import { | ||
getPropertyInfo, | ||
getCustomElementPropertyInfo, | ||
shouldIgnoreAttribute, | ||
shouldRemoveAttribute, | ||
isAttributeNameSafe, | ||
|
@@ -19,6 +20,7 @@ import sanitizeURL from '../shared/sanitizeURL'; | |
import { | ||
disableJavaScriptURLs, | ||
enableTrustedTypesIntegration, | ||
enableCustomElementPropertySupport, | ||
} from 'shared/ReactFeatureFlags'; | ||
import {isOpaqueHydratingObject} from './ReactDOMHostConfig'; | ||
|
||
|
@@ -139,15 +141,49 @@ export function setValueForProperty( | |
value: mixed, | ||
isCustomComponentTag: boolean, | ||
) { | ||
const propertyInfo = getPropertyInfo(name); | ||
let propertyInfo = getPropertyInfo(name); | ||
if (shouldIgnoreAttribute(name, propertyInfo, isCustomComponentTag)) { | ||
return; | ||
} | ||
|
||
if (enableCustomElementPropertySupport && isCustomComponentTag && 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) { | ||
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. Removing this 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 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 |
||
eventName = nameLower; | ||
} | ||
eventName = eventName.slice(2); | ||
|
||
const listenersObjName = eventName + (useCapture ? 'true' : 'false'); | ||
const alreadyHadListener = (node: any)._listeners && (node: any)._listeners[listenersObjName]; | ||
|
||
if (typeof value === 'function' || alreadyHadListener) { | ||
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. 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 Thoughts? 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'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'));
This doesn't seem like consistent behavior. Some possible options:
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. 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 I know that Sebastian seemed objected to reserving everything that starts with 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. 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 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. |
||
if (!(node: any)._listeners) { | ||
gaearon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
(node: any)._listeners = {}; | ||
josepharhar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
(node: any)._listeners[listenersObjName] = value; | ||
const proxy = useCapture ? eventProxyCapture : eventProxy; | ||
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) { | ||
propertyInfo = getCustomElementPropertyInfo(name, node); | ||
} | ||
// If the prop isn't in the special list, treat it as a simple attribute. | ||
if (isCustomComponentTag || propertyInfo === null) { | ||
if (propertyInfo === null || (isCustomComponentTag && !enableCustomElementPropertySupport)) { | ||
if (isAttributeNameSafe(name)) { | ||
const attributeName = name; | ||
if (value === null) { | ||
|
@@ -204,3 +240,11 @@ export function setValueForProperty( | |
} | ||
} | ||
} | ||
|
||
function eventProxy(e: Event) { | ||
josepharhar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
this._listeners[e.type + 'false'](e); | ||
} | ||
|
||
function eventProxyCapture(e: Event) { | ||
this._listeners[e.type + 'true'](e); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,7 +11,7 @@ import type {ReactNodeList} from 'shared/ReactTypes'; | |
|
||
import {Children} from 'react'; | ||
|
||
import {enableFilterEmptyStringAttributesDOM} from 'shared/ReactFeatureFlags'; | ||
import {enableFilterEmptyStringAttributesDOM, enableCustomElementPropertySupport} from 'shared/ReactFeatureFlags'; | ||
|
||
import type { | ||
Destination, | ||
|
@@ -1128,12 +1128,17 @@ function pushStartCustomElement( | |
|
||
let children = null; | ||
let innerHTML = null; | ||
for (const propKey in props) { | ||
for (let propKey in props) { | ||
if (hasOwnProperty.call(props, propKey)) { | ||
const propValue = props[propKey]; | ||
if (propValue == null) { | ||
continue; | ||
} | ||
if (enableCustomElementPropertySupport && propKey === 'className') { | ||
// className gets rendered as class on the client, so it should be | ||
// rendered as class on the server. | ||
propKey = 'class'; | ||
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. What about htmlFor? Is that needed here? 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. className is special because all elements have a builtin 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. Yea this make sense because htmlFor doesn't have any semantics on custom element which are just Generic HTML elements. There are a number of other ones that are case sensitive for similar reasons. contentEditable, tabIndex, etc. They probably work if used correctly, but it would be nice to add a warning if they use the wrong case. There are some that just won't work like Similarly there are some we have to be careful with 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. I'm surprised innerText and textContent aren't reserved properties in React. Doesn't seem right. 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.
contentEditable is interesting, it looks like the attribute can be either
I mean, you can assign to offsetHeight from script and the browser doesn't complain about it... but yeah I guess it would be weird to have a state where you can assign to an attribute called Should I consider every global attribute and every built in property? 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. For the read only properties like offsetHeight, I just pushed a commit which emits the warning: 1a093e5 However, the test fails because it throws an exception when assigning to the read only properties - but this exception doesn't occur in the browser or in JSDOM... what kind of browser/DOM setup is being used in these tests? 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. “use strict” throws, right? 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 wow, today I learned... 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 if you render it on the server and hydrate it wouldn’t error. The reason for the warning would be so that if you render it on the server, during development you should know not to. So you can test hydrating it. 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. Ah, that makes sense! |
||
} | ||
switch (propKey) { | ||
case 'children': | ||
children = propValue; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -206,6 +206,25 @@ export function getPropertyInfo(name: string): PropertyInfo | null { | |
return properties.hasOwnProperty(name) ? properties[name] : null; | ||
} | ||
|
||
export function getCustomElementPropertyInfo( | ||
name: string, | ||
node: Element) { | ||
if (name in (node: any)) { | ||
const acceptsBooleans = (typeof (node: any)[name]) === 'boolean'; | ||
josepharhar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return { | ||
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. This seems a bit slow to do for every property. Likely will need some refactoring. However, is this check also equivalent on the built-ins? 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. Are you referring to |
||
acceptsBooleans, | ||
type: acceptsBooleans ? BOOLEAN : STRING, | ||
mustUseProperty: true, | ||
propertyName: name, | ||
attributeName: name, | ||
attributeNamespace: null, | ||
sanitizeURL: false, | ||
josepharhar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
removeEmptyString: false, | ||
}; | ||
} | ||
return null; | ||
} | ||
|
||
function PropertyInfoRecord( | ||
name: string, | ||
type: PropertyType, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -132,6 +132,12 @@ export const deletedTreeCleanUpLevel = 3; | |
// Note that this should be an uncommon use case and can be avoided by using the transition API. | ||
export const enableSuspenseLayoutEffectSemantics = true; | ||
|
||
// Changes the behavior for rendering custom elements in both server rendering | ||
// and client rendering, mostly to allow JSX attributes to apply to the custom | ||
// element's object properties instead of only HTML attributes. | ||
// https://github.com/facebook/react/issues/11347 | ||
export const enableCustomElementPropertySupport = false; | ||
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. It's ok to set this to That way it'll have some test coverage in CI on and you get a build to experiment with. 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. Awesome!! |
||
|
||
// -------------------------- | ||
// Future APIs to be deprecated | ||
// -------------------------- | ||
|
Uh oh!
There was an error while loading. Please reload this page.