Skip to content

Commit

Permalink
Fix hydration mismatches with React.useId (#2542)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
eps1lon and Andarist committed Nov 14, 2021
1 parent 516fe45 commit eb013d2
Show file tree
Hide file tree
Showing 14 changed files with 620 additions and 127 deletions.
8 changes: 8 additions & 0 deletions .changeset/purple-tigers-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@emotion/jest': minor
---

author: @eps1lon
author: @Andarist

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).
6 changes: 6 additions & 0 deletions .changeset/strange-kids-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@emotion/react': minor
'@emotion/styled': minor
---

Fixed hydration mismatches if `React.useId` (the upcoming API of the React 18) is used within a tree below our components.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@
"react-router-dom": "^4.2.2",
"react-scripts": "1.1.5",
"react-test-renderer": "16.8.6",
"react18": "npm:react@alpha",
"react18-dom": "npm:react-dom@alpha",
"svg-tag-names": "^1.1.1",
"through": "^2.3.8",
"unified": "^6.1.6",
Expand Down
66 changes: 62 additions & 4 deletions packages/jest/src/create-enzyme-serializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,66 @@ import type { Options } from './create-serializer'
import { createSerializer as createEmotionSerializer } from './create-serializer'
import * as enzymeTickler from './enzyme-tickler'
import { createSerializer as createEnzymeToJsonSerializer } from 'enzyme-to-json'
import {
isEmotionCssPropElementType,
isStyledElementType,
unwrapFromPotentialFragment
} from './utils'

const enzymeSerializer = createEnzymeToJsonSerializer({})
const enzymeToJsonSerializer = createEnzymeToJsonSerializer({
map: json => {
if (typeof json.node.type === 'string') {
return json
}
const isRealStyled = json.node.type.__emotion_real === json.node.type
if (isRealStyled) {
return {
...json,
children: json.children.slice(-1)
}
}
return json
}
})

// this is a hack, leveraging the internal/implementation knowledge about the enzyme's ShallowWrapper
// there is no sane way to get this information otherwise though
const getUnrenderedElement = shallowWrapper => {
const symbols = Object.getOwnPropertySymbols(shallowWrapper)
const elementValues = symbols.filter(sym => {
const val = shallowWrapper[sym]
return !!val && val.$$typeof === Symbol.for('react.element')
})
if (elementValues.length !== 1) {
throw new Error(
"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" +
'https://github.com/emotion-js/emotion/issues/new?assignees=&labels=bug%2C+needs+triage&template=--bug-report.md&title='
)
}
return shallowWrapper[elementValues[0]]
}

const wrappedEnzymeSerializer = {
test: enzymeToJsonSerializer.test,
print: (enzymeWrapper, printer) => {
const isShallow = !!enzymeWrapper.dive

if (isShallow && enzymeWrapper.root() === enzymeWrapper) {
const unrendered = getUnrenderedElement(enzymeWrapper)
if (
isEmotionCssPropElementType(unrendered) ||
isStyledElementType(unrendered)
) {
return enzymeToJsonSerializer.print(
unwrapFromPotentialFragment(enzymeWrapper),
printer
)
}
}

return enzymeToJsonSerializer.print(enzymeWrapper, printer)
}
}

export function createEnzymeSerializer({
classNameReplacer,
Expand All @@ -16,7 +74,7 @@ export function createEnzymeSerializer({
})
return {
test(node: *) {
return enzymeSerializer.test(node) || emotionSerializer.test(node)
return wrappedEnzymeSerializer.test(node) || emotionSerializer.test(node)
},
serialize(
node: *,
Expand All @@ -26,9 +84,9 @@ export function createEnzymeSerializer({
refs: *,
printer: Function
) {
if (enzymeSerializer.test(node)) {
if (wrappedEnzymeSerializer.test(node)) {
const tickled = enzymeTickler.tickle(node)
return enzymeSerializer.print(
return wrappedEnzymeSerializer.print(
tickled,
// https://github.com/facebook/jest/blob/470ef2d29c576d6a10de344ec25d5a855f02d519/packages/pretty-format/src/index.ts#L281
valChild => printer(valChild, config, indentation, depth, refs)
Expand Down
69 changes: 34 additions & 35 deletions packages/jest/src/create-serializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,46 +115,45 @@ function isShallowEnzymeElement(
})
}

const createConvertEmotionElements =
(keys: string[], printer: *) => (node: any) => {
if (isPrimitive(node)) {
return node
}
if (isEmotionCssPropEnzymeElement(node)) {
const className = enzymeTickler.getTickledClassName(node.props.css)
const labels = getLabelsFromClassName(keys, className || '')

if (isShallowEnzymeElement(node, keys, labels)) {
const emotionType = node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__
// emotionType will be a string for DOM elements
const type =
typeof emotionType === 'string'
? emotionType
: emotionType.displayName || emotionType.name || 'Component'
return {
...node,
props: filterEmotionProps({
...node.props,
className
}),
type
}
} else {
return node.children[0]
}
}
if (isEmotionCssPropElementType(node)) {
const createConvertEmotionElements = (keys: string[]) => (node: any) => {
if (isPrimitive(node)) {
return node
}
if (isEmotionCssPropEnzymeElement(node)) {
const className = enzymeTickler.getTickledClassName(node.props.css)
const labels = getLabelsFromClassName(keys, className || '')

if (isShallowEnzymeElement(node, keys, labels)) {
const emotionType = node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__
// emotionType will be a string for DOM elements
const type =
typeof emotionType === 'string'
? emotionType
: emotionType.displayName || emotionType.name || 'Component'
return {
...node,
props: filterEmotionProps(node.props),
type: node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__
props: filterEmotionProps({
...node.props,
className
}),
type
}
} else {
return node.children[node.children.length - 1]
}
if (isReactElement(node)) {
return copyProps({}, node)
}
if (isEmotionCssPropElementType(node)) {
return {
...node,
props: filterEmotionProps(node.props),
type: node.props.__EMOTION_TYPE_PLEASE_DO_NOT_USE__
}
return node
}
if (isReactElement(node)) {
return copyProps({}, node)
}
return node
}

function clean(node: any, classNames: string[]) {
if (Array.isArray(node)) {
Expand Down Expand Up @@ -199,7 +198,7 @@ export function createSerializer({
) {
const elements = getStyleElements()
const keys = getKeys(elements)
const convertEmotionElements = createConvertEmotionElements(keys, printer)
const convertEmotionElements = createConvertEmotionElements(keys)
const converted = deepTransform(val, convertEmotionElements)
const nodes = getNodes(converted)
const classNames = getClassNamesFromNodes(nodes)
Expand Down
9 changes: 7 additions & 2 deletions packages/jest/src/enzyme-tickler.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { unwrapFromPotentialFragment } from './utils'

const tickledCssProps = new WeakMap()

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

const wrapped = (isShallow ? el.dive() : el.children()).first()
tickledCssProps.set(cssProp, wrapped.props().className)
const rendered = (isShallow ? el.dive() : el.children()).last()
tickledCssProps.set(
cssProp,
unwrapFromPotentialFragment(rendered).props().className
)
})
return wrapper
}
19 changes: 17 additions & 2 deletions packages/jest/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,15 @@ function getClassNameProp(node) {
return (node && node.prop('className')) || ''
}

function getClassNamesFromEnzyme(selectors, node) {
export function unwrapFromPotentialFragment(node: *) {
if (node.type() === Symbol.for('react.fragment')) {
return node.children().last()
}
return node
}

function getClassNamesFromEnzyme(selectors, nodeWithPotentialFragment) {
const node = unwrapFromPotentialFragment(nodeWithPotentialFragment)
// We need to dive in to get the className if we have a styled element from a shallow render
const isShallow = shouldDive(node)
const nodeWithClassName = findNodeWithClassName(
Expand Down Expand Up @@ -86,11 +94,18 @@ export function isReactElement(val: any): boolean {
export function isEmotionCssPropElementType(val: any): boolean {
return (
val.$$typeof === Symbol.for('react.element') &&
val.type.$$typeof === Symbol.for('react.forward_ref') &&
val.type.displayName === 'EmotionCssPropInternal'
)
}

export function isStyledElementType(val: any): boolean {
if (val.$$typeof !== Symbol.for('react.element')) {
return false
}
const { type } = val
return type.__emotion_real === type
}

export function isEmotionCssPropEnzymeElement(val: any): boolean {
return (
val.$$typeof === Symbol.for('react.test.json') &&
Expand Down

0 comments on commit eb013d2

Please sign in to comment.