diff --git a/.changeset/purple-tigers-breathe.md b/.changeset/purple-tigers-breathe.md new file mode 100644 index 000000000..80e500a4f --- /dev/null +++ b/.changeset/purple-tigers-breathe.md @@ -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). diff --git a/.changeset/strange-kids-change.md b/.changeset/strange-kids-change.md new file mode 100644 index 000000000..3548c9d29 --- /dev/null +++ b/.changeset/strange-kids-change.md @@ -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. diff --git a/package.json b/package.json index 879aefcb2..6c9f77c5d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/jest/src/create-enzyme-serializer.js b/packages/jest/src/create-enzyme-serializer.js index 9208b49b3..52ec73be7 100644 --- a/packages/jest/src/create-enzyme-serializer.js +++ b/packages/jest/src/create-enzyme-serializer.js @@ -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, @@ -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: *, @@ -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) diff --git a/packages/jest/src/create-serializer.js b/packages/jest/src/create-serializer.js index 97c070dfd..75b984c34 100644 --- a/packages/jest/src/create-serializer.js +++ b/packages/jest/src/create-serializer.js @@ -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)) { @@ -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) diff --git a/packages/jest/src/enzyme-tickler.js b/packages/jest/src/enzyme-tickler.js index 6daacfb82..45b52f72c 100644 --- a/packages/jest/src/enzyme-tickler.js +++ b/packages/jest/src/enzyme-tickler.js @@ -1,3 +1,5 @@ +import { unwrapFromPotentialFragment } from './utils' + const tickledCssProps = new WeakMap() export const getTickledClassName = cssProp => tickledCssProps.get(cssProp) @@ -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 } diff --git a/packages/jest/src/utils.js b/packages/jest/src/utils.js index 7b0d71cb6..6eb7dbd14 100644 --- a/packages/jest/src/utils.js +++ b/packages/jest/src/utils.js @@ -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( @@ -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') && diff --git a/packages/jest/test/__snapshots__/react-enzyme.test.js.snap b/packages/jest/test/__snapshots__/react-enzyme.test.js.snap index 28bcdbefe..d7dc8a6ea 100644 --- a/packages/jest/test/__snapshots__/react-enzyme.test.js.snap +++ b/packages/jest/test/__snapshots__/react-enzyme.test.js.snap @@ -56,6 +56,67 @@ exports[`enzyme mount empty styled 1`] = ` `; +exports[`enzyme mount fragment with multiple css prop elements 1`] = ` +.emotion-0 { + background-color: hotpink; +} + +.emotion-1 { + background-color: green; +} + +.emotion-2 { + background-color: blue; +} + + +
+
+
+ +`; + +exports[`enzyme mount multiple selected components 1`] = ` +Array [ + .emotion-0 { + background-color: hotpink; +} + +
  • + hello +
  • , + .emotion-0 { + background-color: blue; +} + +
  • + beautiful +
  • , + .emotion-0 { + background-color: green; +} + +
  • + world +
  • , +] +`; + exports[`enzyme mount nested 1`] = ` .emotion-0 { background-color: red; @@ -129,6 +190,25 @@ exports[`enzyme mount nested styled with css prop 1`] = `
    `; +exports[`enzyme mount parent and child using css property 1`] = ` +.emotion-0 { + background-color: black; +} + +.emotion-1 { + color: white; +} + +
    + Test content +
    +
    +`; + exports[`enzyme mount styled 1`] = ` .emotion-0 { background-color: red; @@ -208,6 +288,8 @@ exports[`enzyme mount theming 1`] = `
    `; +exports[`enzyme mount unmatched selector 1`] = `null`; + exports[`enzyme mount with array of styles as css prop 1`] = ` .emotion-0 { background-color: black; @@ -388,25 +470,6 @@ exports[`enzyme mount with styles on top level 1`] = ` `; -exports[`enzyme parent and child using css property 1`] = ` -.emotion-0 { - background-color: black; -} - -.emotion-1 { - color: white; -} - -
    - Test content -
    -
    -`; - exports[`enzyme shallow basic 1`] = ` .emotion-0 { background-color: red; @@ -453,6 +516,67 @@ exports[`enzyme shallow empty styled 1`] = `
    `; +exports[`enzyme shallow fragment with multiple css prop elements 1`] = ` +.emotion-0 { + background-color: hotpink; +} + +.emotion-1 { + background-color: green; +} + +.emotion-2 { + background-color: blue; +} + + +
    +
    +
    + +`; + +exports[`enzyme shallow multiple selected components 1`] = ` +Array [ + .emotion-0 { + background-color: hotpink; +} + +
  • + hello +
  • , + .emotion-0 { + background-color: blue; +} + +
  • + beautiful +
  • , + .emotion-0 { + background-color: green; +} + +
  • + world +
  • , +] +`; + exports[`enzyme shallow nested 1`] = `
    @@ -495,6 +619,25 @@ exports[`enzyme shallow nested styled with css prop 1`] = `
    `; +exports[`enzyme shallow parent and child using css property 1`] = ` +.emotion-0 { + background-color: black; +} + +.emotion-1 { + color: white; +} + +
    + Test content +
    +
    +`; + exports[`enzyme shallow styled 1`] = ` .emotion-0 { background-color: red; @@ -550,6 +693,8 @@ exports[`enzyme shallow theming 1`] = `
    `; +exports[`enzyme shallow unmatched selector 1`] = `null`; + exports[`enzyme shallow with array of styles as css prop 1`] = ` .emotion-0 { background-color: black; @@ -676,10 +821,13 @@ exports[`enzyme with prop containing css element in fragment 1`] = `
    x -
    - y -
    + Array [ + "", +
    + y +
    , + ]
    `; diff --git a/packages/jest/test/react-enzyme.test.js b/packages/jest/test/react-enzyme.test.js index 2e772a5c6..76888ddad 100644 --- a/packages/jest/test/react-enzyme.test.js +++ b/packages/jest/test/react-enzyme.test.js @@ -14,6 +14,8 @@ import * as serializer from '@emotion/jest/enzyme-serializer' expect.extend(matchers) expect.addSnapshotSerializer(serializer) +const identity = v => v + const cases = { basic: { render() { @@ -278,47 +280,94 @@ const cases = {
    ) } + }, + 'parent and child using css property': { + render() { + const parentStyle = css` + background-color: black; + ` + + const childStyle = css` + color: white; + ` + + return ( +
    + Test content +
    +
    + ) + } + }, + 'fragment with multiple css prop elements': { + render() { + const Component = () => { + return ( + <> +
    +
    +
    + + ) + } + return + } + }, + 'multiple selected components': { + selector: tree => + // with simple `tree.find('[data-item]')` we get elements twice with `mount` since it selects both the css prop element and the host element + tree.findWhere( + n => typeof n.type() !== 'string' && n.props()['data-item'] + ), + render() { + return ( +
      +
    • + {'hello'} +
    • +
    • + {'beautiful'} +
    • +
    • + {'world'} +
    • +
    + ) + } + }, + 'unmatched selector': { + selector: tree => tree.find('div'), + render() { + return ( +
      +
    • {'hello'}
    • +
    • {'beautiful'}
    • +
    • {'world'}
    • +
    + ) + } } } describe('enzyme', () => { jestInCase( 'shallow', - ({ render }) => { + ({ render, selector = identity }) => { const wrapper = enzyme.shallow(render()) - expect(wrapper).toMatchSnapshot() + expect(selector(wrapper)).toMatchSnapshot() }, cases ) jestInCase( 'mount', - ({ render }) => { + ({ render, selector = identity }) => { const wrapper = enzyme.mount(render()) - expect(wrapper).toMatchSnapshot() + expect(selector(wrapper)).toMatchSnapshot() }, cases ) - test('parent and child using css property', () => { - const parentStyle = css` - background-color: black; - ` - - const childStyle = css` - color: white; - ` - - const wrapper = enzyme.mount( -
    - Test content -
    -
    - ) - - expect(wrapper).toMatchSnapshot() - }) - test('with prop containing css element in fragment', () => { const FragmentComponent = () => ( diff --git a/packages/react/__tests__/rehydration.js b/packages/react/__tests__/rehydration.js index 1245ec161..32d300afc 100644 --- a/packages/react/__tests__/rehydration.js +++ b/packages/react/__tests__/rehydration.js @@ -17,8 +17,10 @@ let ReactDOMServer let createCache let css let jsx +let styled let CacheProvider let Global +let ClassNames let createEmotionServer const resetAllModules = () => { @@ -34,7 +36,9 @@ const resetAllModules = () => { jsx = emotionReact.jsx CacheProvider = emotionReact.CacheProvider Global = emotionReact.Global + ClassNames = emotionReact.ClassNames createEmotionServer = require('@emotion/server/create-instance').default + styled = require('@emotion/styled').default } const removeGlobalProp = prop => { @@ -61,6 +65,11 @@ const disableBrowserEnvTemporarily = (fn: () => T): T => { } } +beforeEach(() => { + safeQuerySelector('head').innerHTML = '' + safeQuerySelector('body').innerHTML = '' +}) + test("cache created in render doesn't cause a hydration mismatch", () => { safeQuerySelector('body').innerHTML = [ '
    ', @@ -477,7 +486,6 @@ test('duplicated global styles can be removed safely after rehydrating HTML SSRe } }) - safeQuerySelector('head').innerHTML = '' safeQuerySelector('body').innerHTML = `
    ${app}
    ` expect(safeQuerySelector('html')).toMatchInlineSnapshot(` @@ -592,3 +600,156 @@ test('duplicated global styles can be removed safely after rehydrating HTML SSRe `) }) + +describe('react18', () => { + let previousIsReactActEnvironment + beforeAll(() => { + jest + .mock('react', () => { + return jest.requireActual('react18') + }) + .mock('react-dom', () => { + return jest.requireActual('react18-dom') + }) + .mock('react-dom/server', () => { + return jest.requireActual('react18-dom/server') + }) + + previousIsReactActEnvironment = global.IS_REACT_ACT_ENVIRONMENT + global.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterAll(() => { + jest.clearAllMocks() + global.IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment + }) + + test('no hydration mismatch for styled when using useId', () => { + const finalHTML = disableBrowserEnvTemporarily(() => { + resetAllModules() + + const StyledDivWithId = styled(function DivWithId({ className }) { + const id = (React: any).useId() + return
    + })({ + border: '1px solid black' + }) + + return ReactDOMServer.renderToString() + }) + + safeQuerySelector('body').innerHTML = `
    ${finalHTML}
    ` + + resetAllModules() + + const StyledDivWithId = styled(function DivWithId({ className }) { + const id = (React: any).useId() + return
    + })({ + border: '1px solid black' + }) + + ;(React: any).unstable_act(() => { + ReactDOM.hydrateRoot(safeQuerySelector('#root'), ) + }) + + expect((console.error: any).mock.calls).toMatchInlineSnapshot(`Array []`) + expect((console.warn: any).mock.calls).toMatchInlineSnapshot(`Array []`) + }) + + test('no hydration mismatch for css prop when using useId', () => { + const finalHTML = disableBrowserEnvTemporarily(() => { + resetAllModules() + + function DivWithId({ className }: { className?: string }) { + const id = (React: any).useId() + return
    + } + + return ReactDOMServer.renderToString( + + ) + }) + + safeQuerySelector('body').innerHTML = `
    ${finalHTML}
    ` + + resetAllModules() + + function DivWithId({ className }: { className?: string }) { + const id = (React: any).useId() + return
    + } + + ;(React: any).unstable_act(() => { + ReactDOM.hydrateRoot( + safeQuerySelector('#root'), + + ) + }) + + expect((console.error: any).mock.calls).toMatchInlineSnapshot(`Array []`) + expect((console.warn: any).mock.calls).toMatchInlineSnapshot(`Array []`) + }) + + test('no hydration mismatch for ClassNames when using useId', () => { + const finalHTML = disableBrowserEnvTemporarily(() => { + resetAllModules() + + const DivWithId = ({ className }) => { + const id = (React: any).useId() + return
    + } + + return ReactDOMServer.renderToString( + + {({ css }) => { + return ( + + ) + }} + + ) + }) + + safeQuerySelector('body').innerHTML = `
    ${finalHTML}
    ` + + resetAllModules() + + const DivWithId = ({ className }) => { + const id = (React: any).useId() + return
    + } + + ;(React: any).unstable_act(() => { + ReactDOM.hydrateRoot( + safeQuerySelector('#root'), + + {({ css }) => { + return ( + + ) + }} + + ) + }) + + expect((console.error: any).mock.calls).toMatchInlineSnapshot(`Array []`) + expect((console.warn: any).mock.calls).toMatchInlineSnapshot(`Array []`) + }) +}) diff --git a/packages/react/src/class-names.js b/packages/react/src/class-names.js index 1ac618f3b..deabcdce7 100644 --- a/packages/react/src/class-names.js +++ b/packages/react/src/class-names.js @@ -88,6 +88,8 @@ type Props = { }) => React.Node } +const Noop = () => null + export const ClassNames: React.AbstractComponent = /* #__PURE__ */ withEmotionCache((props, cache) => { let rules = '' @@ -125,21 +127,25 @@ export const ClassNames: React.AbstractComponent = } let ele = props.children(content) hasRendered = true + let possiblyStyleElement = if (!isBrowser && rules.length !== 0) { - return ( - <> -