Skip to content

Commit

Permalink
Compare and format React elements as JSX
Browse files Browse the repository at this point in the history
  • Loading branch information
isaacs committed Sep 28, 2023
1 parent 2a04d7e commit 518a751
Show file tree
Hide file tree
Showing 18 changed files with 792 additions and 163 deletions.
49 changes: 48 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"prismjs": "^1.29.0",
"prismjs-terminal": "^1.2.3",
"react": "^18.2.0",
"react-element-to-jsx-string": "^15.0.0",
"read": "^2.1.0",
"resolve-import": "^1.4.2",
"rimraf": "^5.0.5",
Expand Down
3 changes: 2 additions & 1 deletion src/tcompare/.tshy/commonjs.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"../src/**/*.tsx"
],
"exclude": [
".../src/**/*.mts"
".../src/**/*.mts",
"../src/react-element-to-jsx-string.ts"
],
"compilerOptions": {
"outDir": "../.tshy-build-tmp/commonjs"
Expand Down
17 changes: 17 additions & 0 deletions src/tcompare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,23 @@ Every method and class can take the following options.

Note that `tight` is not suitable for comparisons, only formatting.

- `reactString` - Represent and compare React elements as JSX
strings. Only supported in the `pretty` formatting style.
Enabled by default, set `{ reactString: false }` in the options
to disable it.

When enabled, react elements are _first_ compared as react JSX
strings, and if the strings match, treated as equivalent, even
if they would not otherwise be treated as a match as plain
objects (for example, if `children` is set to `'hello'` vs
`['hello']`, these are considered identical, because they result in the same JSX).

If they do not match, then they are still considered a
match if their plain object represenatations would be
considered a match. So for example, `<x a="b" />` would match
`<x a={/b|c/} />` for functions where strings can match against
regular expressions.

- `bufferChunkSize` - The number of bytes to show per line when
printing long `Buffer` objects. Defaults to 32.

Expand Down
2 changes: 1 addition & 1 deletion src/tcompare/map.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export default t => t.replace('test', 'src')
export default t => t.replace('test', 'src').replace(/\.[^\.]+$/, '.*')
3 changes: 2 additions & 1 deletion src/tcompare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"author": "Isaac Z. Schlueter <i@izs.me> (https://izs.me)",
"license": "BlueOak-1.0.0",
"dependencies": {
"diff": "^5.1.0"
"diff": "^5.1.0",
"react-element-to-jsx-string": "^15.0.0"
},
"tap": {
"typecheck": false,
Expand Down
43 changes: 43 additions & 0 deletions src/tcompare/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export interface FormatOptions {
* Include getter properties
*/
includeGetters?: boolean
/**
* Represent and compare react elements as JSX strings.
*
* Only supported in the 'pretty' formatting style.
*/
reactString?: boolean

/**
* set when formatting keys and values of collections
Expand Down Expand Up @@ -112,6 +118,7 @@ export class Format {

constructor(obj: any, options: FormatOptions = {}) {
this.options = options
options.reactString = options.reactString !== false
this.parent = options.parent || null
this.memo = null
this.sort = !!options.sort
Expand Down Expand Up @@ -214,6 +221,27 @@ export class Format {
)
}

isReactElement(element: any = this.object): boolean {
return (
!!this.options.reactString &&
!!this.style.reactElement &&
!!element &&
typeof element === 'object' &&
typeof element.$$typeof === 'symbol' &&
!!Symbol.keyFor(element.$$typeof)?.startsWith('react.') &&
this.isReactElementChildren(element.props?.children)
)
}
isReactElementChildren(children: any): boolean {
return !children || typeof children === 'string'
? true
: typeof children === 'object'
? children instanceof Set || Array.isArray(children)
? ![...children].some(c => !this.isReactElementChildren(c))
: Format.prototype.isReactElement.call(this, children)
: false
}

// technically this means "is an iterable we don't have another fit for"
// sets, arrays, maps, and streams all handled specially.
isIterable(): boolean {
Expand Down Expand Up @@ -370,6 +398,19 @@ export class Format {
}
}

printReactElement(): void {
// already verified in isReactElement before getting here.
/* c8 ignore start */
if (!this.style.reactElement) return this.printPojo()
/* c8 ignore stop */
const indent = this.indentLevel()
this.memo += this.style
.reactElement(this.object)
.trim()
.split('\n')
.join('\n' + indent)
}

printDate(): void {
this.memo += this.object.toISOString()
}
Expand Down Expand Up @@ -464,6 +505,8 @@ export class Format {
? this.printBuffer()
: this.isArray() && this.objectAsArray
? this.printArray()
: this.isReactElement()
? this.printReactElement()
: // TODO streams, JSX
this.printPojo()
}
Expand Down
9 changes: 9 additions & 0 deletions src/tcompare/src/react-element-to-jsx-string-cjs.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// See: https://github.com/algolia/react-element-to-jsx-string/pull/819
const r =
require('react-element-to-jsx-string') as typeof import('react-element-to-jsx-string')

import type { Options as ReactElementToJSXStringOptions } from 'react-element-to-jsx-string'

export type { ReactElementToJSXStringOptions }

export default r.default
12 changes: 12 additions & 0 deletions src/tcompare/src/react-element-to-jsx-string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// See: https://github.com/algolia/react-element-to-jsx-string/pull/819
import { createRequire } from 'module'
//@ts-ignore
const require = createRequire(import.meta.url)
const r =
require('react-element-to-jsx-string') as typeof import('react-element-to-jsx-string')

import type { Options as ReactElementToJSXStringOptions } from 'react-element-to-jsx-string'

export type { ReactElementToJSXStringOptions }

export default r.default
38 changes: 38 additions & 0 deletions src/tcompare/src/same.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,45 @@ export class Same extends Format {
this.memoExpect += end
}

isReactElement() {
return (
super.isReactElement(this.object) &&
super.isReactElement(this.expect)
)
}
printReactElement() {
const obj = this.simplePrint(this.object)
const exp = this.simplePrintExpect()
this.memo += obj
this.memoExpect += exp

if (obj === exp) {
return
}

// they don't match as JSX strings, but if we would consider the objects
// to be equivalent, then still treat it as a match.
const subDiff = new (this.constructor as typeof Same)(
this.object,
{
...this.options,
expect: this.expect,
parent: this.parent || undefined,
reactString: false,
}
)
subDiff.print()
if (!subDiff.match) {
this.unmatch()
} else {
this.memo += obj
this.memoExpect += obj
}
}

printPojo() {
if (!this.memo) this.memo = ''
if (!this.memoExpect) this.memoExpect = ''
// even though it's not a simple mismatch, it's possible that
// a child entry will cause a mismatch, so we have to print
// the body *before* doing the head. If we still aren't unmatched
Expand Down
14 changes: 14 additions & 0 deletions src/tcompare/src/styles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ReactNode } from 'react'
import type { Format } from './format.js'
import reactElementToJsxString from './react-element-to-jsx-string.js'

// can't use buf.toString('ascii') because that unmasks high bytes
const bufToAscii = (buf: Buffer) =>
Expand Down Expand Up @@ -100,6 +102,9 @@ export interface Style {
/** separator between line number and contents of a long `Buffer` */
bufferKeySep: () => string

/** a react element */
reactElement?: (node: ReactNode) => string

/** an empty string */
stringEmpty: () => string
/** a string that fits on one line */
Expand Down Expand Up @@ -207,6 +212,15 @@ const pretty: Style = {
bufferTail: indent => `\n${indent}>`,
bufferKeySep: () => ': ',

reactElement: (el: ReactNode) =>
reactElementToJsxString(el, {
showDefaultProps: true,
showFunctions: true,
useBooleanShorthandSyntax: true,
sortProps: true,
useFragmentShortSyntax: true,
}),

stringEmpty: () => '""',
stringOneLine: str => JSON.stringify(str),
stringHead: () => 'String(\n',
Expand Down
Loading

0 comments on commit 518a751

Please sign in to comment.