Skip to content

Commit 0c099bd

Browse files
committedMar 8, 2017
Allow specifying styles as Maps to guarantee ordering
Summary: Key ordering in objects can be different in different environments. Sometimes, this causes problems, like where styles generated on the server are not in the same order as the same styles generated on the client. This manifests in problems such as #199 This change lets users manually fix instances where the ordering of elements changes by specifying their styles in an ES6 `Map`, which has defined value ordering. In order to accomplish this, an `OrderedElements` class was created, which is sorta like a `Map` but can only store string keys and lacks most of the features. Internally, `Map`s and objects are converted into this and then these are merged together to preserve the ordering. Fixes #199 Test Plan: - `npm test` @lencioni @ljharb
1 parent dbb3879 commit 0c099bd

8 files changed

+450
-61
lines changed
 

‎README.md

+74
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,80 @@ then we end up with the opposite effect, with `foo` overriding `bar`! The way to
429429
}
430430
```
431431
432+
## Object key ordering
433+
434+
When styles are specified in Aphrodite, the order that they appear in the
435+
actual stylesheet depends on the order that keys are retrieved from the
436+
objects. This ordering is determined by the JavaScript engine that is being
437+
used to render the styles. Sometimes, the order that the styles appear in the
438+
stylesheet matter for the semantics of the CSS. For instance, depending on the
439+
engine, the styles generated from
440+
441+
```js
442+
const styles = StyleSheet.create({
443+
ordered: {
444+
margin: 0,
445+
marginLeft: 15,
446+
},
447+
});
448+
css(styles.ordered);
449+
```
450+
451+
you might expect the following CSS to be generated:
452+
453+
```css
454+
margin: 0px;
455+
margin-left: 15px;
456+
```
457+
458+
but depending on the ordering of the keys in the style object, the CSS might
459+
appear as
460+
461+
```css
462+
margin-left: 15px;
463+
margin: 0px;
464+
```
465+
466+
which is semantically different, because the style which appears later will
467+
override the style before it.
468+
469+
This might also manifest as a problem when server-side rendering, if the
470+
generated styles appear in a different order on the client and on the server.
471+
472+
If you experience this issue where styles don't appear in the generated CSS in
473+
the order that they appear in your objects, there are two solutions:
474+
475+
1. Don't use shorthand properties. For instance, in the margin example above,
476+
by switching from using a shorthand property and a longhand property in the
477+
same styles to using only longhand properties, the issue could be avoided.
478+
479+
```js
480+
const styles = StyleSheet.create({
481+
ordered: {
482+
marginTop: 0,
483+
marginRight: 0,
484+
marginBottom: 0,
485+
marginLeft: 15,
486+
},
487+
});
488+
```
489+
490+
2. Specify the ordering of your styles by specifying them using a
491+
[`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map).
492+
Since `Map`s preserve their insertion order, Aphrodite is able to place your
493+
styles in the correct order.
494+
495+
```js
496+
const styles = StyleSheet.create({
497+
ordered: new Map([
498+
["margin", 0],
499+
["marginLeft", 15],
500+
]),
501+
});
502+
```
503+
504+
Note that `Map`s are not fully supported in all browsers.
505+
432506
## Advanced: Extensions
433507
434508
Extra features can be added to Aphrodite using extensions.

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"babel-core": "^5.8.25",
4545
"babel-loader": "^5.3.2",
4646
"chai": "^3.3.0",
47+
"es6-shim": "^0.35.3",
4748
"eslint": "^3.7.1",
4849
"eslint-config-standard-react": "^4.2.0",
4950
"eslint-plugin-react": "^6.3.0",

‎src/generate.js

+49-40
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* @flow */
22
import prefixAll from 'inline-style-prefixer/static';
33

4+
import OrderedElements from './ordered-elements';
45
import {
56
objectToPairs, kebabifyStyleName, recursiveMerge, stringifyValue,
67
importantify, flatten
@@ -143,18 +144,21 @@ export const generateCSS = (
143144
stringHandlers /* : StringHandlers */,
144145
useImportant /* : boolean */
145146
) /* : string */ => {
146-
const merged = styleTypes.reduce(recursiveMerge);
147+
const merged /* : OrderedElements */ = styleTypes.reduce(
148+
recursiveMerge,
149+
new OrderedElements());
147150

148-
const plainDeclarations = {};
151+
const plainDeclarations = new OrderedElements();
149152
let generatedStyles = "";
150153

151-
Object.keys(merged).forEach(key => {
154+
// TODO(emily): benchmark this to see if a plain for loop would be faster.
155+
merged.forEach((key, val) => {
152156
// For each key, see if one of the selector handlers will handle these
153157
// styles.
154158
const foundHandler = selectorHandlers.some(handler => {
155159
const result = handler(key, selector, (newSelector) => {
156160
return generateCSS(
157-
newSelector, [merged[key]], selectorHandlers,
161+
newSelector, [val], selectorHandlers,
158162
stringHandlers, useImportant);
159163
});
160164
if (result != null) {
@@ -167,7 +171,7 @@ export const generateCSS = (
167171
// If none of the handlers handled it, add it to the list of plain
168172
// style declarations.
169173
if (!foundHandler) {
170-
plainDeclarations[key] = merged[key];
174+
plainDeclarations.set(key, val);
171175
}
172176
});
173177

@@ -186,31 +190,25 @@ export const generateCSS = (
186190
* See generateCSSRuleset for usage and documentation of paramater types.
187191
*/
188192
const runStringHandlers = (
189-
declarations /* : SheetDefinition */,
193+
declarations /* : OrderedElements */,
190194
stringHandlers /* : StringHandlers */,
191195
selectorHandlers /* : SelectorHandler[] */
192196
) /* */ => {
193-
const result = {};
194-
195197
const hasStringHandlers = !!stringHandlers;
196-
const keys = Object.keys(declarations);
197-
for (let i = 0; i < keys.length; i += 1) {
198+
return declarations.map((key, val) => {
198199
// If a handler exists for this particular key, let it interpret
199200
// that value first before continuing
200-
if (hasStringHandlers && stringHandlers.hasOwnProperty(keys[i])) {
201+
if (hasStringHandlers && stringHandlers.hasOwnProperty(key)) {
201202
// TODO(emily): Pass in a callback which generates CSS, similar to
202203
// how our selector handlers work, instead of passing in
203204
// `selectorHandlers` and have them make calls to `generateCSS`
204205
// themselves. Right now, this is impractical because our string
205206
// handlers are very specialized and do complex things.
206-
result[keys[i]] = stringHandlers[keys[i]](
207-
declarations[keys[i]], selectorHandlers);
207+
return stringHandlers[key](val, selectorHandlers);
208208
} else {
209-
result[keys[i]] = declarations[keys[i]];
209+
return val;
210210
}
211-
}
212-
213-
return result;
211+
});
214212
};
215213

216214
/**
@@ -246,44 +244,55 @@ const runStringHandlers = (
246244
*/
247245
export const generateCSSRuleset = (
248246
selector /* : string */,
249-
declarations /* : SheetDefinition */,
247+
declarations /* : OrderedElements */,
250248
stringHandlers /* : StringHandlers */,
251249
useImportant /* : boolean */,
252250
selectorHandlers /* : SelectorHandler[] */
253251
) /* : string */ => {
254-
const handledDeclarations = runStringHandlers(
252+
const handledDeclarations /* : OrderedElements */ = runStringHandlers(
255253
declarations, stringHandlers, selectorHandlers);
256254

257-
const prefixedDeclarations = prefixAll(handledDeclarations);
255+
const prefixedDeclarations = prefixAll(handledDeclarations.elements);
258256

259257
const prefixedRules = flatten(
260258
objectToPairs(prefixedDeclarations).map(([key, value]) => {
261259
if (Array.isArray(value)) {
262-
// inline-style-prefix-all returns an array when there should be
263-
// multiple rules, we will flatten to single rules
264-
265-
const prefixedValues = [];
266-
const unprefixedValues = [];
267-
268-
value.forEach(v => {
269-
if (v[0] === '-') {
270-
prefixedValues.push(v);
271-
} else {
272-
unprefixedValues.push(v);
273-
}
274-
});
275-
276-
prefixedValues.sort();
277-
unprefixedValues.sort();
278-
279-
return prefixedValues
280-
.concat(unprefixedValues)
281-
.map(v => [key, v]);
260+
// inline-style-prefixer returns an array when there should be
261+
// multiple rules for the same key. Here we flatten to multiple
262+
// pairs with the same key.
263+
return value.map(v => [key, v]);
282264
}
283265
return [[key, value]];
284266
})
285267
);
286268

269+
// Calculate the order that we want to each element in `prefixedRules` to
270+
// be in, based on its index in the original key ordering.
271+
const sortOrder = {};
272+
for (let i = 0; i < handledDeclarations.keyOrder.length; i++) {
273+
const key = handledDeclarations.keyOrder[i];
274+
sortOrder[key] = i;
275+
276+
// In order to keep most prefixed versions of keys in about the same
277+
// order that the original keys were in but placed before the
278+
// unprefixed version, we generate the prefixed forms of the keys and
279+
// set their order to the same as the original key minus a little bit.
280+
const capitalizedKey = `${key[0].toUpperCase()}${key.slice(1)}`;
281+
sortOrder[`Webkit${capitalizedKey}`] = i - 0.5;
282+
sortOrder[`Moz${capitalizedKey}`] = i - 0.5;
283+
sortOrder[`ms${capitalizedKey}`] = i - 0.5;
284+
}
285+
286+
// Sort the prefixed rules according to the order that the keys were
287+
// in originally before we prefixed them. New, prefixed versions
288+
// of the rules aren't in the original list, so we set their order to -1 so
289+
// they sort to the top.
290+
prefixedRules.sort((a, b) => {
291+
const aOrder = sortOrder.hasOwnProperty(a[0]) ? sortOrder[a[0]] : -1;
292+
const bOrder = sortOrder.hasOwnProperty(b[0]) ? sortOrder[b[0]] : -1;
293+
return aOrder - bOrder;
294+
});
295+
287296
const transformValue = (useImportant === false)
288297
? stringifyValue
289298
: (key, value) => importantify(stringifyValue(key, value));

‎src/ordered-elements.js

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/* @flow */
2+
/* global Map */
3+
4+
export default class OrderedElements {
5+
/* ::
6+
elements: {[string]: any};
7+
keyOrder: string[];
8+
9+
static fromObject: ({[string]: any}) => OrderedElements;
10+
static fromMap: (Map<string,any>) => OrderedElements;
11+
static from: (Map<string,any> | {[string]: any} | OrderedElements) =>
12+
OrderedElements;
13+
*/
14+
15+
constructor(
16+
elements /* : {[string]: any} */ = {},
17+
keyOrder /* : string[] */ = []
18+
) {
19+
this.elements = elements;
20+
this.keyOrder = keyOrder;
21+
}
22+
23+
forEach(callback /* : (string, any) => void */) {
24+
for (let i = 0; i < this.keyOrder.length; i++) {
25+
callback(this.keyOrder[i], this.elements[this.keyOrder[i]]);
26+
}
27+
}
28+
29+
map(callback /* : (string, any) => any */) /* : OrderedElements */ {
30+
const results = new OrderedElements();
31+
for (let i = 0; i < this.keyOrder.length; i++) {
32+
results.set(
33+
this.keyOrder[i],
34+
callback(this.keyOrder[i], this.elements[this.keyOrder[i]])
35+
);
36+
}
37+
return results;
38+
}
39+
40+
set(key /* : string */, value /* : any */) {
41+
if (!this.elements.hasOwnProperty(key)) {
42+
this.keyOrder.push(key);
43+
}
44+
this.elements[key] = value;
45+
}
46+
47+
get(key /* : string */) /* : any */ {
48+
return this.elements[key];
49+
}
50+
51+
has(key /* : string */) /* : boolean */ {
52+
return this.elements.hasOwnProperty(key);
53+
}
54+
}
55+
56+
OrderedElements.fromObject = (obj) => {
57+
return new OrderedElements(obj, Object.keys(obj));
58+
};
59+
60+
OrderedElements.fromMap = (map) => {
61+
const ret = new OrderedElements();
62+
map.forEach((val, key) => {
63+
ret.set(key, val);
64+
});
65+
return ret;
66+
};
67+
68+
OrderedElements.from = (obj) => {
69+
if (obj instanceof OrderedElements) {
70+
// NOTE(emily): This makes a shallow copy of the previous elements, so
71+
// if the elements are deeply modified it will affect all copies.
72+
return new OrderedElements({...obj.elements}, obj.keyOrder.slice());
73+
} else if (
74+
// For some reason, flow complains about a plain
75+
// `typeof Map !== "undefined"` check. Casting `Map` to `any` solves
76+
// the problem.
77+
typeof /*::(*/ Map /*: any)*/ !== "undefined" &&
78+
obj instanceof Map
79+
) {
80+
return OrderedElements.fromMap(obj);
81+
} else {
82+
return OrderedElements.fromObject(obj);
83+
}
84+
};

‎src/util.js

+19-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/* @flow */
22

3+
import OrderedElements from './ordered-elements';
4+
35
/* ::
46
type Pair = [ string, any ];
57
type Pairs = Pair[];
@@ -41,28 +43,33 @@ export const kebabifyStyleName = (string /* : string */) /* : string */ => {
4143
return result;
4244
};
4345

44-
const isNotObject = (
46+
const isPlainObject = (
4547
x/* : ObjectMap | any */
46-
) /* : boolean */ => typeof x !== 'object' || Array.isArray(x) || x === null;
48+
) /* : boolean */ => typeof x === 'object' && !Array.isArray(x) && x !== null;
4749

4850
export const recursiveMerge = (
49-
a /* : ObjectMap | any */,
50-
b /* : ObjectMap */
51-
) /* : ObjectMap */ => {
51+
a /* : OrderedElements | ObjectMap | Map<string,any> | any */,
52+
b /* : ObjectMap | Map<string,any> */
53+
) /* : OrderedElements | any */ => {
5254
// TODO(jlfwong): Handle malformed input where a and b are not the same
5355
// type.
5456

55-
if (isNotObject(a) || isNotObject(b)) {
56-
return b;
57+
if (!isPlainObject(a) || !isPlainObject(b)) {
58+
if (isPlainObject(b)) {
59+
return OrderedElements.from(b);
60+
} else {
61+
return b;
62+
}
5763
}
5864

59-
const ret = {...a};
65+
const ret = OrderedElements.from(a);
66+
const right = OrderedElements.from(b);
6067

61-
Object.keys(b).forEach(key => {
62-
if (ret.hasOwnProperty(key)) {
63-
ret[key] = recursiveMerge(a[key], b[key]);
68+
right.forEach((key, val) => {
69+
if (ret.has(key)) {
70+
ret.set(key, recursiveMerge(ret.get(key), val));
6471
} else {
65-
ret[key] = b[key];
72+
ret.set(key, val)
6673
}
6774
});
6875

0 commit comments

Comments
 (0)