diff --git a/packages/rax-server-renderer/package.json b/packages/rax-server-renderer/package.json
index ff55b355c2..1977bad4dd 100644
--- a/packages/rax-server-renderer/package.json
+++ b/packages/rax-server-renderer/package.json
@@ -1,6 +1,6 @@
{
"name": "rax-server-renderer",
- "version": "1.1.2",
+ "version": "1.1.3",
"description": "Rax renderer for server-side render.",
"license": "BSD-3-Clause",
"main": "lib/index.js",
diff --git a/packages/rax-server-renderer/src/__tests__/attributes.js b/packages/rax-server-renderer/src/__tests__/attributes.js
new file mode 100644
index 0000000000..210dadc484
--- /dev/null
+++ b/packages/rax-server-renderer/src/__tests__/attributes.js
@@ -0,0 +1,782 @@
+/* @jsx createElement */
+
+import {createElement} from 'rax';
+import {renderToString} from '../index';
+
+describe('renderToString', () => {
+ describe('property to attribute mapping', function() {
+ describe('string properties', function() {
+ it('simple numbers ', () => {
+ function MyComponent() {
+ return
{}} />;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('unknown events', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('custom attribute named `on`', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+ });
+ });
+
+ describe('custom elements', () => {
+ it('class for custom elements', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('className for custom elements', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('htmlFor attribute for custom elements', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('for attribute for custom elements', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('unknown attributes for custom elements', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('unknown `on*` attributes for custom elements', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('no unknown boolean `true` attributes', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('no unknown boolean `false` attributes', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('no unknown attributes for custom elements with null value', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('unknown attributes for custom elements using is', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('no unknown attributes for custom elements using is with null value', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ const str = renderToString(
);
+ expect(str).toBe('
');
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/rax-server-renderer/src/__tests__/basic.js b/packages/rax-server-renderer/src/__tests__/basic.js
new file mode 100644
index 0000000000..58a0700f3f
--- /dev/null
+++ b/packages/rax-server-renderer/src/__tests__/basic.js
@@ -0,0 +1,114 @@
+/* @jsx createElement */
+
+import {createElement} from 'rax';
+import {renderToString} from '../index';
+
+describe('renderToString', () => {
+ describe('basic rendering', function() {
+ it('a blank div', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ let str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('a self-closing tag', () => {
+ function MyComponent() {
+ return
;
+ }
+
+ let str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('a self-closing tag as a child', () => {
+ function MyComponent() {
+ return (
+
+
+
+ );
+ }
+
+ let str = renderToString(
);
+ expect(str).toBe('
');
+ });
+
+ it('a string', () => {
+ function MyComponent() {
+ return 'Hello';
+ }
+
+ let str = renderToString(
);
+ expect(str).toBe('Hello');
+ });
+
+ it('a number', () => {
+ function MyComponent() {
+ return 42;
+ }
+
+ let str = renderToString(
);
+ expect(str).toBe('42');
+ });
+
+ it('an array with one child', () => {
+ function MyComponent() {
+ return [
text1
];
+ }
+
+ let str = renderToString(
);
+ expect(str).toBe('
text1
');
+ });
+
+ it('an array with several children', () => {
+ let Header = props => {
+ return
header
;
+ };
+ let Footer = props => {
+ return [
footer
,
about
];
+ };
+
+ function MyComponent() {
+ return [
+
text1
,
+
text2,
+
,
+
,
+ ];
+ }
+
+ let str = renderToString(
);
+ expect(str).toBe('
text1
text2header
footer
about
');
+ });
+
+ it('a nested array', () => {
+ function MyComponent() {
+ return [
+ [
text1
],
+
text2,
+ [[[null,
], false]],
+ ];
+ }
+
+ let str = renderToString(
);
+ expect(str).toBe('
text1
text2');
+ });
+
+ // TODO: render an iterable
+ it('an iterable', async() => {
+ });
+
+ it('emptyish value', () => {
+ expect(renderToString(0)).toBe('0');
+ expect(renderToString(
{''}
)).toBe('
');
+ expect(renderToString([])).toBe('');
+ expect(renderToString(false)).toBe('');
+ expect(renderToString(true)).toBe('');
+ expect(renderToString(undefined)).toBe('');
+ expect(renderToString([[[false]], undefined])).toBe('');
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/rax-server-renderer/src/__tests__/renderToString.js b/packages/rax-server-renderer/src/__tests__/renderToString.js
index bef41d1615..c008e60620 100644
--- a/packages/rax-server-renderer/src/__tests__/renderToString.js
+++ b/packages/rax-server-renderer/src/__tests__/renderToString.js
@@ -249,7 +249,7 @@ describe('renderToString', () => {
}
let str = renderToString(
);
- expect(str).toBe('
');
+ expect(str).toBe('
');
});
it('render with state hook', () => {
diff --git a/packages/rax-server-renderer/src/attribute.js b/packages/rax-server-renderer/src/attribute.js
new file mode 100644
index 0000000000..91cc41075a
--- /dev/null
+++ b/packages/rax-server-renderer/src/attribute.js
@@ -0,0 +1,129 @@
+// A simple string attribute.
+// Attributes that aren't in the whitelist are presumed to have this type.
+export const STRING = 1;
+
+// A string attribute that accepts booleans in Rax. In HTML, these are called
+// "enumerated" attributes with "true" and "false" as possible values.
+// When true, it should be set to a "true" string.
+// When false, it should be set to a "false" string.
+export const BOOLEANISH_STRING = 2;
+
+// A real boolean attribute.
+// When true, it should be present (set either to an empty string or its name).
+// When false, it should be omitted.
+export const BOOLEAN = 3;
+
+// An attribute that can be used as a flag as well as with a value.
+// When true, it should be present (set either to an empty string or its name).
+// When false, it should be omitted.
+// For any other value, should be present with that value.
+export const OVERLOADED_BOOLEAN = 4;
+
+// An attribute that must be numeric or parse as a numeric.
+// When falsy, it should be removed.
+export const NUMERIC = 5;
+
+// An attribute that must be positive numeric or parse as a positive numeric.
+// When falsy, it should be removed.
+export const POSITIVE_NUMERIC = 6;
+
+const properties = {};
+
+export function getPropertyInfo(prop) {
+ return properties[prop];
+}
+
+export function shouldRemoveAttribute(prop, value) {
+ const propertyInfo = getPropertyInfo(prop);
+ const propType = propertyInfo ? propertyInfo.type : null;
+ const valueType = typeof value;
+
+ if (value === null || valueType === 'undefined') {
+ return true;
+ }
+
+ switch (valueType) {
+ case 'function':
+ case 'symbol':
+ return true;
+ }
+
+ if (propType !== null) {
+ switch (propType) {
+ case BOOLEAN:
+ return !value;
+ case OVERLOADED_BOOLEAN:
+ return value === false;
+ case NUMERIC:
+ return isNaN(value);
+ case POSITIVE_NUMERIC:
+ return isNaN(value) || value < 1;
+ }
+ }
+
+ return false;
+}
+
+[
+ 'contentEditable',
+ 'draggable',
+ 'spellCheck',
+ 'value'
+].forEach((name) => {
+ properties[name] = {
+ type: BOOLEANISH_STRING
+ };
+});
+
+[
+ 'allowFullScreen',
+ 'async',
+ 'autoFocus',
+ 'autoPlay',
+ 'controls',
+ 'default',
+ 'defer',
+ 'disabled',
+ 'disablePictureInPicture',
+ 'formNoValidate',
+ 'hidden',
+ 'loop',
+ 'noModule',
+ 'noValidate',
+ 'open',
+ 'playsInline',
+ 'readOnly',
+ 'required',
+ 'reversed',
+ 'scoped',
+ 'seamless',
+ 'itemScope',
+ 'checked',
+ 'multiple',
+ 'muted',
+ 'selected',
+].forEach((name) => {
+ properties[name] = {
+ type: BOOLEAN
+ };
+});
+
+[
+ 'capture',
+ 'download'
+].forEach((name) => {
+ properties[name] = {
+ type: OVERLOADED_BOOLEAN
+ };
+});
+
+[
+ 'cols',
+ 'rows',
+ 'size',
+ 'span',
+].forEach((name) => {
+ properties[name] = {
+ type: POSITIVE_NUMERIC
+ };
+});
\ No newline at end of file
diff --git a/packages/rax-server-renderer/src/index.js b/packages/rax-server-renderer/src/index.js
index 328c0535ec..912f487951 100644
--- a/packages/rax-server-renderer/src/index.js
+++ b/packages/rax-server-renderer/src/index.js
@@ -1,4 +1,5 @@
import { shared } from 'rax';
+import { BOOLEAN, BOOLEANISH_STRING, OVERLOADED_BOOLEAN, shouldRemoveAttribute, getPropertyInfo } from './attribute';
const EMPTY_OBJECT = {};
const TRUE = true;
@@ -121,37 +122,78 @@ function styleToCSS(style, options = {}) {
return css;
}
+function createMarkupForProperty(prop, value, options) {
+ if (prop === 'children') {
+ // Ignore children prop
+ return '';
+ }
+
+ if (prop === 'style') {
+ return ` style="${styleToCSS(value, options)}"`;
+ }
+
+ if (prop === 'className') {
+ return typeof value === 'string' ? ` class="${escapeText(value)}"` : '';
+ }
+
+ if (prop === 'dangerouslySetInnerHTML') {
+ // Ignore innerHTML
+ return '';
+ }
+
+ if (shouldRemoveAttribute(prop, value)) {
+ return '';
+ }
+
+ const propInfo = getPropertyInfo(prop);
+ const propType = propInfo ? propInfo.type : null;
+ const valueType = typeof value;
+
+ if (propType === BOOLEAN || propType === OVERLOADED_BOOLEAN && value === true) {
+ return ` ${prop}`;
+ }
+
+ if (valueType === 'string') {
+ return ` ${prop}="${escapeText(value)}"`;
+ }
+
+ if (valueType === 'number') {
+ return ` ${prop}="${String(value)}"`;
+ }
+
+ if (valueType === 'boolean') {
+ if (propType === BOOLEANISH_STRING || prop.indexOf('data-') === 0 || prop.indexOf('aria-') === 0) {
+ return ` ${prop}="${value ? 'true' : 'false'}"`;
+ }
+ }
+
+ return '';
+};
+
function propsToString(props, options) {
let html = '';
for (var prop in props) {
var value = props[prop];
- if (prop === 'children') {
- // Ignore children prop
- } else if (prop === 'style') {
- html = html + ` style="${styleToCSS(value, options)}"`;
- } else if (prop === 'className') {
- html = html + ` class="${escapeText(value)}"`;
- } else if (prop === 'defaultValue') {
- if (!props.value) {
- html = html + ` value="${typeof value === 'string' ? escapeText(value) : value}"`;
- }
- } else if (prop === 'defaultChecked') {
+ if (prop === 'defaultValue') {
if (!props.checked) {
- html = html + ` checked="${value}"`;
+ prop = 'value';
+ } else {
+ continue;
}
- } else if (prop === 'dangerouslySetInnerHTML') {
- // Ignore innerHTML
- } else {
- if (typeof value === 'string') {
- html = html + ` ${prop}="${escapeText(value)}"`;
- } else if (typeof value === 'number') {
- html = html + ` ${prop}="${String(value)}"`;
- } else if (typeof value === 'boolean') {
- html = html + ` ${prop}`;
+ }
+
+ if (prop === 'defaultChecked') {
+ if (!props.checked) {
+ prop = 'checked';
+ } else {
+ continue;
}
}
+
+ html = html + createMarkupForProperty(prop, value, options);
}
+
return html;
}