Skip to content

Commit

Permalink
Support React.memo
Browse files Browse the repository at this point in the history
Detect memoized elements & dereference their underlying type. Format
such elements with a different annotation, similar to function types.
Retain backwards compatibility with elements serialized before
React.memo support was added.
  • Loading branch information
novemberborn committed Jan 12, 2019
1 parent 5e2b624 commit 9cf3e51
Show file tree
Hide file tree
Showing 13 changed files with 142 additions and 21 deletions.
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,11 @@ component](https://facebook.github.io/react/docs/components-and-props.html)
elements, the element type is compared by identity. After deserialization the
element types are compared by function name.

Component elements are formatted with a ⍟ character after the element
name. Properties and children are formatted by [Concordance](https://github.com/concordancejs/concordance).
[Memoized elements](https://reactjs.org/docs/react-api.html#reactmemo) are
supported, however different memoizations of the same function are considered
equal if used with the same properties.

Memoized elements are formatted with a ⍝ character after the element
name. Component elements are formatted with a ⍟ character. Properties and
children are formatted by
[Concordance](https://github.com/concordancejs/concordance).
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ exports.serializerVersion = 2
exports.theme = {
react: {
functionType: '\u235F',
memoizedType: `\u235D`,
openTag: {
start: '<',
end: '>',
Expand Down
30 changes: 24 additions & 6 deletions lib/elementFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const diffShallow = require('./diffShallow')
const escapeText = require('./escapeText')

const FRAGMENT_NAME = Symbol.for('react.fragment')
const MEMO_TYPE = Symbol.for('react.memo')

function factory (api, reactTags) {
const tag = Symbol('@concordance/react.ElementValue')
Expand Down Expand Up @@ -62,7 +63,11 @@ function factory (api, reactTags) {
function describe (props) {
const element = props.value

const type = element.type
let type = element.type
const hasMemoizedType = type.$$typeof === MEMO_TYPE
// Dereference underlying type if memoized.
if (hasMemoizedType) type = type.type

const hasTypeFn = typeof type === 'function'
const typeFn = hasTypeFn ? type : null
const name = hasTypeFn ? type.displayName || type.name : type
Expand All @@ -78,6 +83,7 @@ function factory (api, reactTags) {

return new DescribedElementValue(Object.assign({
children,
hasMemoizedType,
hasProperties,
hasTypeFn,
name,
Expand All @@ -96,6 +102,7 @@ function factory (api, reactTags) {
super(props)
this.isFragment = props.name === FRAGMENT_NAME
this.name = props.name
this.hasMemoizedType = props.hasMemoizedType
this.hasProperties = props.hasProperties
this.hasTypeFn = props.hasTypeFn

Expand All @@ -110,13 +117,15 @@ function factory (api, reactTags) {

formatName (theme) {
const formatted = api.wrapFromTheme(theme.react.tagName, this.isFragment ? 'React.Fragment' : this.name)
return this.hasTypeFn
? formatted + theme.react.functionType
: formatted
if (this.hasMemoizedType) return formatted + theme.react.memoizedType
if (this.hasTypeFn) return formatted + theme.react.functionType
return formatted
}

compareNames (expected) {
return this.name === expected.name && this.hasTypeFn === expected.hasTypeFn
return this.name === expected.name &&
this.hasMemoizedType === expected.hasMemoizedType &&
this.hasTypeFn === expected.hasTypeFn
}

formatShallow (theme, indent) {
Expand Down Expand Up @@ -216,7 +225,15 @@ function factory (api, reactTags) {
}

serialize () {
return [this.isFragment, this.isFragment ? null : this.name, this.hasProperties, this.hasTypeFn, super.serialize()]
// TODO: Reorder hasMemoizedType before next major release.
return [
this.isFragment,
this.isFragment ? null : this.name,
this.hasProperties,
this.hasTypeFn,
super.serialize(),
this.hasMemoizedType
]
}
}
Object.defineProperty(ElementValue.prototype, 'tag', {value: tag})
Expand Down Expand Up @@ -328,6 +345,7 @@ function factory (api, reactTags) {
super(state[4], recursor)
this.isFragment = state[0]
this.name = this.isFragment ? FRAGMENT_NAME : state[1]
this.hasMemoizedType = state.length === 5 ? false : state[5]
this.hasProperties = state[2]
this.hasTypeFn = state[3]
}
Expand Down
24 changes: 24 additions & 0 deletions test/backcompat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import test from 'ava'
import {compareDescriptors, describe, deserialize} from 'concordance'

import React from 'react'

import plugin from '..'
import HelloMessage from './fixtures/react/HelloMessage'

const plugins = [plugin]

const equalsSerialization = (t, buffer, getValue) => {
const expected = describe(getValue(), {plugins})

const deserialized = deserialize(buffer, {plugins})
t.true(
compareDescriptors(deserialized, expected),
'the deserialized descriptor equals the expected value')
}

test('element serialization before React.memo support was added',
equalsSerialization,
Buffer.from('AwAfAAAAAQERARJAY29uY29yZGFuY2UvcmVhY3QBAgEBAQEBAlwAAABiAAAAEwEFEBEBDEhlbGxvTWVzc2FnZQ8PEwEGEQEGT2JqZWN0AQERAQZPYmplY3QQEBAAAQ0AAQEAAQ8AEwECFAEDAAEFEQEEbmFtZRQBAwABBREBBEpvaG4=', 'base64'), // eslint-disable-line max-len
() => <HelloMessage name='John' />
)
11 changes: 10 additions & 1 deletion test/compare.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React from 'react'
import renderer from 'react-test-renderer'

import plugin from '..'
import HelloMessage from './fixtures/react/HelloMessage'
import HelloMessage, {MemoizedHelloMessage} from './fixtures/react/HelloMessage'

const plugins = [plugin]
const render = value => renderer.create(value).toJSON()
Expand All @@ -30,6 +30,15 @@ test('react elements', macros,
() => React.createElement('Foo'),
() => React.createElement('Bar'))

test('memoized elements', macros,
() => <MemoizedHelloMessage name='John' />,
() => <MemoizedHelloMessage name='Olivia' />)

test('different memoizations are equal', t => {
const SecondHelloMessage = React.memo(HelloMessage)
t.true(concordance.compare(<MemoizedHelloMessage name='John' />, <SecondHelloMessage name='John' />, {plugins}).pass)
})

test('fragments', macros,
() => <React.Fragment><HelloMessage name='John' /></React.Fragment>,
() => <React.Fragment><HelloMessage name='Olivia' /></React.Fragment>)
Expand Down
10 changes: 9 additions & 1 deletion test/diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React from 'react'
import renderer from 'react-test-renderer'

import plugin from '..'
import HelloMessage from './fixtures/react/HelloMessage'
import HelloMessage, {MemoizedHelloMessage} from './fixtures/react/HelloMessage'

const plugins = [plugin]

Expand All @@ -28,6 +28,14 @@ test('react elements', macros,
() => <strong>arm</strong>,
() => <em>arm</em>)

test('memoized elements', macros,
() => <MemoizedHelloMessage name='John' />,
() => <MemoizedHelloMessage name='Olivia' />)

test('memoized elements against non-memoized elements', macros,
() => <MemoizedHelloMessage name='John' />,
() => <HelloMessage name='John' />)

test('fragments', macros,
() => <React.Fragment><HelloMessage name='John' /></React.Fragment>,
() => <React.Fragment><HelloMessage name='Olivia' /></React.Fragment>)
Expand Down
2 changes: 2 additions & 0 deletions test/fixtures/react/HelloMessage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ export default class HelloMessage extends React.Component {
return <div>Hello <NameHighlight name={this.props.name} /></div>
}
}

export const MemoizedHelloMessage = React.memo(HelloMessage)
3 changes: 2 additions & 1 deletion test/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React from 'react'
import renderer from 'react-test-renderer'

import plugin from '..'
import HelloMessage from './fixtures/react/HelloMessage'
import HelloMessage, {MemoizedHelloMessage} from './fixtures/react/HelloMessage'

const plugins = [plugin]
const format = (value, options) => concordance.format(value, Object.assign({plugins}, options))
Expand All @@ -22,6 +22,7 @@ snapshotRendered.title = prefix => `formats rendered ${prefix}`
const macros = [snapshot, snapshotRendered]

test('react elements', macros, () => <HelloMessage name='John' />)
test('memoized elements', macros, () => <MemoizedHelloMessage name='John' />)
test('fragments', macros, () => <React.Fragment><HelloMessage name='John' /></React.Fragment>)
test('object properties', macros, () => {
return React.createElement('Foo', {object: {baz: 'thud'}})
Expand Down
12 changes: 2 additions & 10 deletions test/serialize-and-encode.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React from 'react'
import renderer from 'react-test-renderer'

import plugin from '..'
import HelloMessage from './fixtures/react/HelloMessage'
import HelloMessage, {MemoizedHelloMessage} from './fixtures/react/HelloMessage'

const plugins = [plugin]

Expand Down Expand Up @@ -46,15 +46,7 @@ useDeserializedRendered.title = prefix => `deserialized rendered ${prefix} is eq
const macros = [useDeserialized, useDeserializedRendered]

test('react elements', macros, () => <HelloMessage name='John' />)
// TODO: Combine next two tests with `macros` array
test.failing('memoized react elements', useDeserialized, () => {
const MemoizedHelloMessage = React.memo(HelloMessage)
return <MemoizedHelloMessage name='John' />
})
test('memoized react elements', useDeserializedRendered, () => {
const MemoizedHelloMessage = React.memo(HelloMessage)
return <MemoizedHelloMessage name='John' />
})
test('memoized elements', macros, () => <MemoizedHelloMessage name='John' />)
test('fragments', macros, () => <React.Fragment><HelloMessage name='John' /></React.Fragment>)
test('object properties', macros, () => {
return React.createElement('Foo', {object: {baz: 'thud'}})
Expand Down
41 changes: 41 additions & 0 deletions test/snapshots/diff.js.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,24 @@ Generated by [AVA](https://ava.li).
</div>␊
</div>`

## diffs memoized elements

> Snapshot 1
` <HelloMessage⍝␊
- name="John"␊
+ name="Olivia"␊
/>`

## diffs memoized elements against non-memoized elements

> Snapshot 1
`- <HelloMessage⍝␊
+ <HelloMessage⍟␊
name="John"␊
/>`

## diffs multiline string properties

> Snapshot 1
Expand Down Expand Up @@ -447,6 +465,29 @@ Generated by [AVA](https://ava.li).
</div>␊
</div>`

## diffs rendered memoized elements

> Snapshot 1
` <div>␊
Hello ␊
<mark>␊
- John␊
+ Olivia␊
</mark>␊
</div>`

## diffs rendered memoized elements against non-memoized elements

> Snapshot 1
` <div>␊
Hello ␊
<mark>␊
John␊
</mark>␊
</div>`

## diffs rendered multiline string properties

> Snapshot 1
Expand Down
Binary file modified test/snapshots/diff.js.snap
Binary file not shown.
19 changes: 19 additions & 0 deletions test/snapshots/format.js.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ Generated by [AVA](https://ava.li).
</div>␊
</div>`

## formats memoized elements

> Snapshot 1
`<HelloMessage⍝␊
name="John"␊
/>`

## formats multiline string properties

> Snapshot 1
Expand Down Expand Up @@ -184,6 +192,17 @@ Generated by [AVA](https://ava.li).
</div>␊
</div>`

## formats rendered memoized elements

> Snapshot 1
`<div>␊
Hello ␊
<mark>␊
John␊
</mark>␊
</div>`

## formats rendered multiline string properties

> Snapshot 1
Expand Down
Binary file modified test/snapshots/format.js.snap
Binary file not shown.

0 comments on commit 9cf3e51

Please sign in to comment.