Skip to content

Commit eb013d2

Browse files
eps1lonAndarist
andauthored
Fix hydration mismatches with React.useId (#2542)
* Current behavior for useId * fix(styled): Ensure no hydration mismatch with React.useId * yarn changeset * Update snapshots * Dirty fix for enzyme matchers * Fragment -> Noop * Apply same pattern to class-names and emotion-element * tweak fragment unwrapping in `@emotion/jest` * Fixed enzyme-related serialization tests * Fix more cases and add new tests * add tests for avoiding id mismatches when using css prop and ClassNames component * Fix flow errors * Reset html before each rehydration test * tweak changesets Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
1 parent 516fe45 commit eb013d2

14 files changed

+620
-127
lines changed

.changeset/purple-tigers-breathe.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@emotion/jest': minor
3+
---
4+
5+
author: @eps1lon
6+
author: @Andarist
7+
8+
Adjusted the serialization logic to unwrap rendered elements from Fragments that had to be added to fix hydration mismatches caused by `React.useId` usage (the upcoming API of the React 18).

.changeset/strange-kids-change.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@emotion/react': minor
3+
'@emotion/styled': minor
4+
---
5+
6+
Fixed hydration mismatches if `React.useId` (the upcoming API of the React 18) is used within a tree below our components.

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@
261261
"react-router-dom": "^4.2.2",
262262
"react-scripts": "1.1.5",
263263
"react-test-renderer": "16.8.6",
264+
"react18": "npm:react@alpha",
265+
"react18-dom": "npm:react-dom@alpha",
264266
"svg-tag-names": "^1.1.1",
265267
"through": "^2.3.8",
266268
"unified": "^6.1.6",

packages/jest/src/create-enzyme-serializer.js

+62-4
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,66 @@ import type { Options } from './create-serializer'
33
import { createSerializer as createEmotionSerializer } from './create-serializer'
44
import * as enzymeTickler from './enzyme-tickler'
55
import { createSerializer as createEnzymeToJsonSerializer } from 'enzyme-to-json'
6+
import {
7+
isEmotionCssPropElementType,
8+
isStyledElementType,
9+
unwrapFromPotentialFragment
10+
} from './utils'
611

7-
const enzymeSerializer = createEnzymeToJsonSerializer({})
12+
const enzymeToJsonSerializer = createEnzymeToJsonSerializer({
13+
map: json => {
14+
if (typeof json.node.type === 'string') {
15+
return json
16+
}
17+
const isRealStyled = json.node.type.__emotion_real === json.node.type
18+
if (isRealStyled) {
19+
return {
20+
...json,
21+
children: json.children.slice(-1)
22+
}
23+
}
24+
return json
25+
}
26+
})
27+
28+
// this is a hack, leveraging the internal/implementation knowledge about the enzyme's ShallowWrapper
29+
// there is no sane way to get this information otherwise though
30+
const getUnrenderedElement = shallowWrapper => {
31+
const symbols = Object.getOwnPropertySymbols(shallowWrapper)
32+
const elementValues = symbols.filter(sym => {
33+
const val = shallowWrapper[sym]
34+
return !!val && val.$$typeof === Symbol.for('react.element')
35+
})
36+
if (elementValues.length !== 1) {
37+
throw new Error(
38+
"Could not get unrendered element reliably from the Enzyme's ShallowWrapper. This is a bug in Emotion - please open an issue with repro steps included:\n" +
39+
'https://github.com/emotion-js/emotion/issues/new?assignees=&labels=bug%2C+needs+triage&template=--bug-report.md&title='
40+
)
41+
}
42+
return shallowWrapper[elementValues[0]]
43+
}
44+
45+
const wrappedEnzymeSerializer = {
46+
test: enzymeToJsonSerializer.test,
47+
print: (enzymeWrapper, printer) => {
48+
const isShallow = !!enzymeWrapper.dive
49+
50+
if (isShallow && enzymeWrapper.root() === enzymeWrapper) {
51+
const unrendered = getUnrenderedElement(enzymeWrapper)
52+
if (
53+
isEmotionCssPropElementType(unrendered) ||
54+
isStyledElementType(unrendered)
55+
) {
56+
return enzymeToJsonSerializer.print(
57+
unwrapFromPotentialFragment(enzymeWrapper),
58+
printer
59+
)
60+
}
61+
}
62+
63+
return enzymeToJsonSerializer.print(enzymeWrapper, printer)
64+
}
65+
}
866

967
export function createEnzymeSerializer({
1068
classNameReplacer,
@@ -16,7 +74,7 @@ export function createEnzymeSerializer({
1674
})
1775
return {
1876
test(node: *) {
19-
return enzymeSerializer.test(node) || emotionSerializer.test(node)
77+
return wrappedEnzymeSerializer.test(node) || emotionSerializer.test(node)
2078
},
2179
serialize(
2280
node: *,
@@ -26,9 +84,9 @@ export function createEnzymeSerializer({
2684
refs: *,
2785
printer: Function
2886
) {
29-
if (enzymeSerializer.test(node)) {
87+
if (wrappedEnzymeSerializer.test(node)) {
3088
const tickled = enzymeTickler.tickle(node)
31-
return enzymeSerializer.print(
89+
return wrappedEnzymeSerializer.print(
3290
tickled,
3391
// https://github.com/facebook/jest/blob/470ef2d29c576d6a10de344ec25d5a855f02d519/packages/pretty-format/src/index.ts#L281
3492
valChild => printer(valChild, config, indentation, depth, refs)

packages/jest/src/create-serializer.js

+34-35
Original file line numberDiff line numberDiff line change
@@ -115,46 +115,45 @@ function isShallowEnzymeElement(
115115
})
116116
}
117117

118-
const createConvertEmotionElements =
119-
(keys: string[], printer: *) => (node: any) => {
120-
if (isPrimitive(node)) {
121-
return node
122-
}
123-
if (isEmotionCssPropEnzymeElement(node)) {
124-
const className = enzymeTickler.getTickledClassName(node.props.css)
125-
const labels = getLabelsFromClassName(keys, className || '')
126-
127-
if (isShallowEnzymeElement(node, keys, labels)) {
128-
const emotionType = node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__
129-
// emotionType will be a string for DOM elements
130-
const type =
131-
typeof emotionType === 'string'
132-
? emotionType
133-
: emotionType.displayName || emotionType.name || 'Component'
134-
return {
135-
...node,
136-
props: filterEmotionProps({
137-
...node.props,
138-
className
139-
}),
140-
type
141-
}
142-
} else {
143-
return node.children[0]
144-
}
145-
}
146-
if (isEmotionCssPropElementType(node)) {
118+
const createConvertEmotionElements = (keys: string[]) => (node: any) => {
119+
if (isPrimitive(node)) {
120+
return node
121+
}
122+
if (isEmotionCssPropEnzymeElement(node)) {
123+
const className = enzymeTickler.getTickledClassName(node.props.css)
124+
const labels = getLabelsFromClassName(keys, className || '')
125+
126+
if (isShallowEnzymeElement(node, keys, labels)) {
127+
const emotionType = node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__
128+
// emotionType will be a string for DOM elements
129+
const type =
130+
typeof emotionType === 'string'
131+
? emotionType
132+
: emotionType.displayName || emotionType.name || 'Component'
147133
return {
148134
...node,
149-
props: filterEmotionProps(node.props),
150-
type: node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__
135+
props: filterEmotionProps({
136+
...node.props,
137+
className
138+
}),
139+
type
151140
}
141+
} else {
142+
return node.children[node.children.length - 1]
152143
}
153-
if (isReactElement(node)) {
154-
return copyProps({}, node)
144+
}
145+
if (isEmotionCssPropElementType(node)) {
146+
return {
147+
...node,
148+
props: filterEmotionProps(node.props),
149+
type: node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__
155150
}
156-
return node
157151
}
152+
if (isReactElement(node)) {
153+
return copyProps({}, node)
154+
}
155+
return node
156+
}
158157

159158
function clean(node: any, classNames: string[]) {
160159
if (Array.isArray(node)) {
@@ -199,7 +198,7 @@ export function createSerializer({
199198
) {
200199
const elements = getStyleElements()
201200
const keys = getKeys(elements)
202-
const convertEmotionElements = createConvertEmotionElements(keys, printer)
201+
const convertEmotionElements = createConvertEmotionElements(keys)
203202
const converted = deepTransform(val, convertEmotionElements)
204203
const nodes = getNodes(converted)
205204
const classNames = getClassNamesFromNodes(nodes)

packages/jest/src/enzyme-tickler.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { unwrapFromPotentialFragment } from './utils'
2+
13
const tickledCssProps = new WeakMap()
24

35
export const getTickledClassName = cssProp => tickledCssProps.get(cssProp)
@@ -12,8 +14,11 @@ export const tickle = wrapper => {
1214
return
1315
}
1416

15-
const wrapped = (isShallow ? el.dive() : el.children()).first()
16-
tickledCssProps.set(cssProp, wrapped.props().className)
17+
const rendered = (isShallow ? el.dive() : el.children()).last()
18+
tickledCssProps.set(
19+
cssProp,
20+
unwrapFromPotentialFragment(rendered).props().className
21+
)
1722
})
1823
return wrapper
1924
}

packages/jest/src/utils.js

+17-2
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,15 @@ function getClassNameProp(node) {
5858
return (node && node.prop('className')) || ''
5959
}
6060

61-
function getClassNamesFromEnzyme(selectors, node) {
61+
export function unwrapFromPotentialFragment(node: *) {
62+
if (node.type() === Symbol.for('react.fragment')) {
63+
return node.children().last()
64+
}
65+
return node
66+
}
67+
68+
function getClassNamesFromEnzyme(selectors, nodeWithPotentialFragment) {
69+
const node = unwrapFromPotentialFragment(nodeWithPotentialFragment)
6270
// We need to dive in to get the className if we have a styled element from a shallow render
6371
const isShallow = shouldDive(node)
6472
const nodeWithClassName = findNodeWithClassName(
@@ -86,11 +94,18 @@ export function isReactElement(val: any): boolean {
8694
export function isEmotionCssPropElementType(val: any): boolean {
8795
return (
8896
val.$$typeof === Symbol.for('react.element') &&
89-
val.type.$$typeof === Symbol.for('react.forward_ref') &&
9097
val.type.displayName === 'EmotionCssPropInternal'
9198
)
9299
}
93100

101+
export function isStyledElementType(val: any): boolean {
102+
if (val.$$typeof !== Symbol.for('react.element')) {
103+
return false
104+
}
105+
const { type } = val
106+
return type.__emotion_real === type
107+
}
108+
94109
export function isEmotionCssPropEnzymeElement(val: any): boolean {
95110
return (
96111
val.$$typeof === Symbol.for('react.test.json') &&

0 commit comments

Comments
 (0)