From 232a47ad0493fa2664f3205986dbc73ac5061bff Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Wed, 23 Dec 2015 17:34:27 +0000 Subject: [PATCH] Pass SVG attributes through All attributes defined on SVG elements will now be passed directly regardless of the whitelist. The casing specified by user will be preserved, and setAttribute() will be used. In the future we will remove support for the camel case aliases to the hyphenated attributes. For example, we currently map `strokeWidth` to `stroke-width` but this is now deprecated behind a warning. When we remove support for this we can remove some of the code paths introduced in this commit. The purpose of this change is to stop maintaining a separate SVG property config. The config still exists for two purposes: * Allow a migration path for deprecated camelcased versions of hyphenated SVG attributes * Track special namespaced attributes (they still require a whitelist) However it is no longer a blocker for using new non-namespaced SVG attributes, and users don't have to ask us to add them to the whitelist. Fixes #1657 --- .../dom/client/__tests__/ReactDOMSVG-test.js | 39 +++++ .../dom/shared/DOMPropertyOperations.js | 77 +++++++++ src/renderers/dom/shared/ReactDOMComponent.js | 13 ++ .../dom/shared/SVGDOMPropertyConfig.js | 37 ---- .../__tests__/ReactDOMComponent-test.js | 162 ++++++++++++++++++ 5 files changed, 291 insertions(+), 37 deletions(-) diff --git a/src/renderers/dom/client/__tests__/ReactDOMSVG-test.js b/src/renderers/dom/client/__tests__/ReactDOMSVG-test.js index 2898d2c2e47e3..b932b136721f2 100644 --- a/src/renderers/dom/client/__tests__/ReactDOMSVG-test.js +++ b/src/renderers/dom/client/__tests__/ReactDOMSVG-test.js @@ -21,6 +21,45 @@ describe('ReactDOMSVG', function() { ReactDOMServer = require('ReactDOMServer'); }); + it('creates initial markup for known hyphenated attributes', function() { + var markup = ReactDOMServer.renderToString( + + ); + expect(markup).toContain('clip-path="url(#starlet)"'); + }); + + it('creates initial markup for camel case attributes', function() { + var markup = ReactDOMServer.renderToString( + + ); + expect(markup).toContain('viewBox="0 0 100 100"'); + }); + + it('deprecates camel casing of hyphenated attributes', function() { + spyOn(console, 'error'); + var markup = ReactDOMServer.renderToString( + + ); + expect(markup).toContain('clip-path="url(#starlet)"'); + expect(console.error.argsForCall.length).toBe(1); + expect(console.error.argsForCall[0][0]).toContain('clipPath'); + expect(console.error.argsForCall[0][0]).toContain('clip-path'); + }); + + it('creates initial markup for unknown hyphenated attributes', function() { + var markup = ReactDOMServer.renderToString( + + ); + expect(markup).toContain('the-word="the-bird"'); + }); + + it('creates initial markup for unknown camel case attributes', function() { + var markup = ReactDOMServer.renderToString( + + ); + expect(markup).toContain('theWord="theBird"'); + }); + it('creates initial namespaced markup', function() { var markup = ReactDOMServer.renderToString( diff --git a/src/renderers/dom/shared/DOMPropertyOperations.js b/src/renderers/dom/shared/DOMPropertyOperations.js index 5375edc23a488..0fdb7e0480774 100644 --- a/src/renderers/dom/shared/DOMPropertyOperations.js +++ b/src/renderers/dom/shared/DOMPropertyOperations.js @@ -50,6 +50,32 @@ function shouldIgnoreValue(propertyInfo, value) { (propertyInfo.hasOverloadedBooleanValue && value === false); } +if (__DEV__) { + var reactProps = { + children: true, + dangerouslySetInnerHTML: true, + key: true, + ref: true, + }; + var warnedSVGProperties = {}; + + var warnDeprecatedSVGProperty = function(name, attributeName) { + if (reactProps.hasOwnProperty(name) && reactProps[name] || + warnedSVGProperties.hasOwnProperty(name) && warnedSVGProperties[name]) { + return; + } + + warnedSVGProperties[name] = true; + warning( + false, + 'SVG property %s is deprecated. Use the original attribute name ' + + '%s for SVG tags instead.', + name, + attributeName + ); + }; +} + /** * Operations for dealing with DOM properties. */ @@ -124,6 +150,31 @@ var DOMPropertyOperations = { return name + '=' + quoteAttributeValueForBrowser(value); }, + /** + * Creates markup for an SVG property. + * + * @param {string} name + * @param {*} value + * @return {string} Markup string, or empty string if the property was invalid. + */ + createMarkupForSVGAttribute: function(name, value) { + if (!isAttributeNameSafe(name) || value == null) { + return ''; + } + var propertyInfo = DOMProperty.properties.hasOwnProperty(name) ? + DOMProperty.properties[name] : null; + if (propertyInfo) { + var { attributeName, attributeNamespace } = propertyInfo; + if (__DEV__) { + if (!attributeNamespace && name !== attributeName) { + warnDeprecatedSVGProperty(name, attributeName); + } + } + return attributeName + '=' + quoteAttributeValueForBrowser(value); + } + return name + '=' + quoteAttributeValueForBrowser(value); + }, + /** * Sets the value for a property on a node. * @@ -183,6 +234,22 @@ var DOMPropertyOperations = { } }, + setValueForSVGAttribute: function(node, name, value) { + var propertyInfo = DOMProperty.properties.hasOwnProperty(name) ? + DOMProperty.properties[name] : null; + if (propertyInfo) { + if (__DEV__) { + var { attributeName, attributeNamespace } = propertyInfo; + if (!attributeNamespace && name !== attributeName) { + warnDeprecatedSVGProperty(name, attributeName); + } + } + DOMPropertyOperations.setValueForProperty(node, name, value); + } else { + DOMPropertyOperations.setValueForAttribute(node, name, value); + } + }, + /** * Deletes the value for a property on a node. * @@ -217,6 +284,16 @@ var DOMPropertyOperations = { } }, + deleteValueForSVGAttribute: function(node, name) { + var propertyInfo = DOMProperty.properties.hasOwnProperty(name) ? + DOMProperty.properties[name] : null; + if (propertyInfo) { + DOMPropertyOperations.deleteValueForProperty(node, name); + } else { + node.removeAttribute(name); + } + }, + }; ReactPerf.measureMethods(DOMPropertyOperations, 'DOMPropertyOperations', { diff --git a/src/renderers/dom/shared/ReactDOMComponent.js b/src/renderers/dom/shared/ReactDOMComponent.js index 65b76b892b7cf..c3202db6ac0ee 100644 --- a/src/renderers/dom/shared/ReactDOMComponent.js +++ b/src/renderers/dom/shared/ReactDOMComponent.js @@ -647,6 +647,8 @@ ReactDOMComponent.Mixin = { if (propKey !== CHILDREN) { markup = DOMPropertyOperations.createMarkupForCustomAttribute(propKey, propValue); } + } else if (this._namespaceURI === DOMNamespaces.svg) { + markup = DOMPropertyOperations.createMarkupForSVGAttribute(propKey, propValue); } else { markup = DOMPropertyOperations.createMarkupForProperty(propKey, propValue); } @@ -862,6 +864,11 @@ ReactDOMComponent.Mixin = { DOMProperty.properties[propKey] || DOMProperty.isCustomAttribute(propKey)) { DOMPropertyOperations.deleteValueForProperty(getNode(this), propKey); + } else if (this._namespaceURI === DOMNamespaces.svg) { + DOMPropertyOperations.deleteValueForSVGAttribute( + getNode(this), + propKey + ); } } for (propKey in nextProps) { @@ -924,6 +931,12 @@ ReactDOMComponent.Mixin = { propKey, nextProp ); + } else if (this._namespaceURI === DOMNamespaces.svg) { + DOMPropertyOperations.setValueForSVGAttribute( + getNode(this), + propKey, + nextProp + ); } else if ( DOMProperty.properties[propKey] || DOMProperty.isCustomAttribute(propKey)) { diff --git a/src/renderers/dom/shared/SVGDOMPropertyConfig.js b/src/renderers/dom/shared/SVGDOMPropertyConfig.js index 8dad8b1e60790..d1299791a2281 100644 --- a/src/renderers/dom/shared/SVGDOMPropertyConfig.js +++ b/src/renderers/dom/shared/SVGDOMPropertyConfig.js @@ -23,46 +23,19 @@ var NS = { var SVGDOMPropertyConfig = { Properties: { clipPath: MUST_USE_ATTRIBUTE, - cx: MUST_USE_ATTRIBUTE, - cy: MUST_USE_ATTRIBUTE, - d: MUST_USE_ATTRIBUTE, - dx: MUST_USE_ATTRIBUTE, - dy: MUST_USE_ATTRIBUTE, - fill: MUST_USE_ATTRIBUTE, fillOpacity: MUST_USE_ATTRIBUTE, fontFamily: MUST_USE_ATTRIBUTE, fontSize: MUST_USE_ATTRIBUTE, - fx: MUST_USE_ATTRIBUTE, - fy: MUST_USE_ATTRIBUTE, - gradientTransform: MUST_USE_ATTRIBUTE, - gradientUnits: MUST_USE_ATTRIBUTE, markerEnd: MUST_USE_ATTRIBUTE, markerMid: MUST_USE_ATTRIBUTE, markerStart: MUST_USE_ATTRIBUTE, - offset: MUST_USE_ATTRIBUTE, - opacity: MUST_USE_ATTRIBUTE, - patternContentUnits: MUST_USE_ATTRIBUTE, - patternUnits: MUST_USE_ATTRIBUTE, - points: MUST_USE_ATTRIBUTE, - preserveAspectRatio: MUST_USE_ATTRIBUTE, - r: MUST_USE_ATTRIBUTE, - rx: MUST_USE_ATTRIBUTE, - ry: MUST_USE_ATTRIBUTE, - spreadMethod: MUST_USE_ATTRIBUTE, stopColor: MUST_USE_ATTRIBUTE, stopOpacity: MUST_USE_ATTRIBUTE, - stroke: MUST_USE_ATTRIBUTE, strokeDasharray: MUST_USE_ATTRIBUTE, strokeLinecap: MUST_USE_ATTRIBUTE, strokeOpacity: MUST_USE_ATTRIBUTE, strokeWidth: MUST_USE_ATTRIBUTE, textAnchor: MUST_USE_ATTRIBUTE, - transform: MUST_USE_ATTRIBUTE, - version: MUST_USE_ATTRIBUTE, - viewBox: MUST_USE_ATTRIBUTE, - x1: MUST_USE_ATTRIBUTE, - x2: MUST_USE_ATTRIBUTE, - x: MUST_USE_ATTRIBUTE, xlinkActuate: MUST_USE_ATTRIBUTE, xlinkArcrole: MUST_USE_ATTRIBUTE, xlinkHref: MUST_USE_ATTRIBUTE, @@ -73,9 +46,6 @@ var SVGDOMPropertyConfig = { xmlBase: MUST_USE_ATTRIBUTE, xmlLang: MUST_USE_ATTRIBUTE, xmlSpace: MUST_USE_ATTRIBUTE, - y1: MUST_USE_ATTRIBUTE, - y2: MUST_USE_ATTRIBUTE, - y: MUST_USE_ATTRIBUTE, }, DOMAttributeNamespaces: { xlinkActuate: NS.xlink, @@ -94,15 +64,9 @@ var SVGDOMPropertyConfig = { fillOpacity: 'fill-opacity', fontFamily: 'font-family', fontSize: 'font-size', - gradientTransform: 'gradientTransform', - gradientUnits: 'gradientUnits', markerEnd: 'marker-end', markerMid: 'marker-mid', markerStart: 'marker-start', - patternContentUnits: 'patternContentUnits', - patternUnits: 'patternUnits', - preserveAspectRatio: 'preserveAspectRatio', - spreadMethod: 'spreadMethod', stopColor: 'stop-color', stopOpacity: 'stop-opacity', strokeDasharray: 'stroke-dasharray', @@ -110,7 +74,6 @@ var SVGDOMPropertyConfig = { strokeOpacity: 'stroke-opacity', strokeWidth: 'stroke-width', textAnchor: 'text-anchor', - viewBox: 'viewBox', xlinkActuate: 'xlink:actuate', xlinkArcrole: 'xlink:arcrole', xlinkHref: 'xlink:href', diff --git a/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js b/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js index 1e920255932b0..368c2ba38cbb0 100644 --- a/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js +++ b/src/renderers/dom/shared/__tests__/ReactDOMComponent-test.js @@ -260,6 +260,75 @@ describe('ReactDOMComponent', function() { expect(container.firstChild.hasAttribute('height')).toBe(false); }); + it('should remove known SVG camel case attributes', function() { + var container = document.createElement('div'); + ReactDOM.render(, container); + + expect(container.firstChild.hasAttribute('viewBox')).toBe(true); + ReactDOM.render(, container); + expect(container.firstChild.hasAttribute('viewBox')).toBe(false); + }); + + it('should remove known SVG hyphenated attributes', function() { + var container = document.createElement('div'); + ReactDOM.render(, container); + + expect(container.firstChild.hasAttribute('clip-path')).toBe(true); + ReactDOM.render(, container); + expect(container.firstChild.hasAttribute('clip-path')).toBe(false); + }); + + it('should remove arbitrary SVG hyphenated attributes', function() { + var container = document.createElement('div'); + ReactDOM.render(, container); + + expect(container.firstChild.hasAttribute('the-word')).toBe(true); + ReactDOM.render(, container); + expect(container.firstChild.hasAttribute('the-word')).toBe(false); + }); + + it('should remove arbitrary SVG camel case attributes', function() { + var container = document.createElement('div'); + ReactDOM.render(, container); + + expect(container.firstChild.hasAttribute('theWord')).toBe(true); + ReactDOM.render(, container); + expect(container.firstChild.hasAttribute('theWord')).toBe(false); + }); + + it('should remove SVG attributes that should have been hyphenated', function() { + spyOn(console, 'error'); + var container = document.createElement('div'); + ReactDOM.render(, container); + expect(console.error.argsForCall.length).toBe(1); + expect(console.error.argsForCall[0][0]).toContain('clipPath'); + expect(console.error.argsForCall[0][0]).toContain('clip-path'); + + expect(container.firstChild.hasAttribute('clip-path')).toBe(true); + ReactDOM.render(, container); + expect(container.firstChild.hasAttribute('clip-path')).toBe(false); + }); + + it('should remove namespaced SVG attributes', function() { + var container = document.createElement('div'); + ReactDOM.render( + + + , + container + ); + + expect(container.firstChild.firstChild.hasAttributeNS( + 'http://www.w3.org/1999/xlink', + 'href' + )).toBe(true); + ReactDOM.render(, container); + expect(container.firstChild.firstChild.hasAttributeNS( + 'http://www.w3.org/1999/xlink', + 'href' + )).toBe(false); + }); + it('should remove properties', function() { var container = document.createElement('div'); ReactDOM.render(
, container); @@ -331,6 +400,99 @@ describe('ReactDOMComponent', function() { expect(container.childNodes[0].getAttribute('myattr')).toBe('myval'); }); + it('should update known hyphenated attributes for SVG tags', function() { + var container = document.createElement('div'); + + var beforeUpdate = React.createElement('svg', {}, null); + ReactDOM.render(beforeUpdate, container); + + var afterUpdate = ; + ReactDOM.render(afterUpdate, container); + + expect(container.childNodes[0].getAttribute('clip-path')).toBe( + 'url(#starlet)' + ); + }); + + it('should update camel case attributes for SVG tags', function() { + var container = document.createElement('div'); + + var beforeUpdate = React.createElement('svg', {}, null); + ReactDOM.render(beforeUpdate, container); + + var afterUpdate = ; + ReactDOM.render(afterUpdate, container); + + expect(container.childNodes[0].getAttribute('viewBox')).toBe( + '0 0 100 100' + ); + }); + + it('should warn camel casing hyphenated attributes for SVG tags', function() { + spyOn(console, 'error'); + var container = document.createElement('div'); + + var beforeUpdate = React.createElement('svg', {}, null); + ReactDOM.render(beforeUpdate, container); + + var afterUpdate = ; + ReactDOM.render(afterUpdate, container); + + expect(container.childNodes[0].getAttribute('clip-path')).toBe( + 'url(#starlet)' + ); + expect(console.error.argsForCall.length).toBe(1); + expect(console.error.argsForCall[0][0]).toContain('clipPath'); + expect(console.error.argsForCall[0][0]).toContain('clip-path'); + }); + + it('should update arbitrary hyphenated attributes for SVG tags', function() { + var container = document.createElement('div'); + + var beforeUpdate = React.createElement('svg', {}, null); + ReactDOM.render(beforeUpdate, container); + + var afterUpdate = ; + ReactDOM.render(afterUpdate, container); + + expect(container.childNodes[0].getAttribute('the-word')).toBe('the-bird'); + }); + + it('should update arbitrary camel case attributes for SVG tags', function() { + var container = document.createElement('div'); + + var beforeUpdate = React.createElement('svg', {}, null); + ReactDOM.render(beforeUpdate, container); + + var afterUpdate = ; + ReactDOM.render(afterUpdate, container); + + expect(container.childNodes[0].getAttribute('theWord')).toBe('theBird'); + }); + + it('should update namespaced SVG attributes', function() { + var container = document.createElement('div'); + + var beforeUpdate = ( + + + + ); + ReactDOM.render(beforeUpdate, container); + + var afterUpdate = ( + + + + ); + ReactDOM.render(afterUpdate, container); + + expect(container.firstChild.firstChild.getAttributeNS( + 'http://www.w3.org/1999/xlink', + 'href' + )).toBe('http://i.imgur.com/JvqCM2p.png'); + }); + it('should clear all the styles when removing `style`', function() { var styles = {display: 'none', color: 'red'}; var container = document.createElement('div');